@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,218 @@
1
+ import { Contract, rpc, type Transaction } from '@stellar/stellar-sdk';
2
+ import { DEFAULT_GRAMMAR_VERSION } from './build-add-context-rule.ts';
3
+ /** Minimal Soroban RPC surface the install pipeline needs. Test seams
4
+ * inject a stub so the builder stays deterministic; production builds
5
+ * the real one via `createRpcServer` from `../record/rpc.ts`. */
6
+ export interface InstallRpcClient {
7
+ getAccount(address: string): Promise<{
8
+ sequenceNumber(): string;
9
+ }>;
10
+ simulateTransaction(tx: Transaction | Parameters<rpc.Server['simulateTransaction']>[0]): Promise<rpc.Api.SimulateTransactionResponse>;
11
+ getLatestLedger(): Promise<{
12
+ sequence: number;
13
+ }>;
14
+ /** Live `grammar_version()` lookup. Returns the deployed u32. */
15
+ getContractVersion(address: string): Promise<number>;
16
+ }
17
+ /** Convert a real rpc.Server into the InstallRpcClient surface. The
18
+ * `getContractVersion` lookup uses `simulateTransaction` against
19
+ * `contract.call('grammar_version')` and decodes the returned u32.
20
+ *
21
+ * The passphrase is a REQUIRED argument rather than something read off the
22
+ * server: `rpc.Server` does not carry one, so reaching for
23
+ * `server.networkPassphrase` yielded a non-string and every version probe
24
+ * died with "Invalid passphrase provided to Transaction". The caller knows
25
+ * which network it dialled; make it say so. */
26
+ export declare function rpcClientFromServer(server: rpc.Server, networkPassphrase: string): InstallRpcClient;
27
+ /** Inputs for the install-policy build. */
28
+ export interface BuildInstallPolicyArgs {
29
+ /** The smart account contract address (C...). */
30
+ smartAccount: string;
31
+ /** The signer that authorises the install (G... wallet). */
32
+ sourceAccount: string;
33
+ /** Network passphrase - pins the hash the auth digest binds. */
34
+ networkPassphrase: string;
35
+ /** The new rule to install. Mirrors the core `ContextRuleDraft`. */
36
+ rule: BuildInstallPolicyRuleDraft;
37
+ /** Per-rule install nonce; 1 for a fresh install. */
38
+ installNonce: number;
39
+ /** Already-encoded (base64) canonical ScVal of the predicate. */
40
+ encodedPredicate: string;
41
+ /** Hex sha256 of the canonical predicate XDR bytes. */
42
+ predicateHash: string;
43
+ /** Per-policy oracle overrides. */
44
+ oracleParams?: {
45
+ maxStalenessSeconds?: number;
46
+ maxDeviationBps?: number;
47
+ maxCrossFeedDeviationBps?: number;
48
+ };
49
+ /** Grammar version override; defaults to 1. */
50
+ grammarVersion?: number;
51
+ /** Base fee in stroops; defaults to BASE_FEE (100). */
52
+ baseFee?: number;
53
+ /** RPC client to use for the simulation pass; injected for tests. */
54
+ rpc: InstallRpcClient;
55
+ /** Ledger window (in ledgers) for the auth entry's `validUntil`. */
56
+ authValidUntilLedgers?: number;
57
+ }
58
+ export type BuildInstallPolicyRuleDraft = {
59
+ contextRuleType: {
60
+ kind: 'default';
61
+ } | {
62
+ kind: 'call_contract';
63
+ contract: string;
64
+ } | {
65
+ kind: 'create_contract';
66
+ wasmHash: string;
67
+ };
68
+ name: string;
69
+ validUntilLedger: number | null;
70
+ signers: BuildInstallPolicySignerDraft[];
71
+ policies: BuildInstallPolicyPolicyRef[];
72
+ };
73
+ export type BuildInstallPolicySignerDraft = {
74
+ kind: 'delegated';
75
+ address: string;
76
+ } | {
77
+ kind: 'external';
78
+ verifier: string;
79
+ keyBytes: string;
80
+ };
81
+ export type BuildInstallPolicyPolicyRef = {
82
+ kind: 'interpreter';
83
+ interpreterAddress: string;
84
+ predicateBlobBase64: string;
85
+ } | {
86
+ kind: 'oz_builtin';
87
+ instanceAddress: string;
88
+ primitive: {
89
+ primitive: 'spending_limit' | 'simple_threshold' | 'weighted_threshold';
90
+ params: Record<string, unknown>;
91
+ };
92
+ };
93
+ /** Human-readable description of the install call, decoded FROM the built
94
+ * XDR (not from the input args). The XDR is the source of truth: a
95
+ * human approving a review card needs the description to mirror the bytes
96
+ * the wallet will sign. Deriving `describes` from the input args would
97
+ * re-describe the caller's intent rather than the transaction. */
98
+ export interface InstallCallDescribes {
99
+ /** Smart account that owns the new rule (echoed from the XDR). */
100
+ targetContract: string;
101
+ /** The fn name on the target (always `add_context_rule` today). */
102
+ fnName: 'add_context_rule';
103
+ /** The new rule's display name, decoded from args[1]. */
104
+ ruleName: string;
105
+ /** The `validUntil` ledger sequence decoded from args[2] (null when void). */
106
+ validUntilLedger: number | null;
107
+ /** The signers attached to the rule, decoded from args[3]. The
108
+ * `verifier`/`keyBytes` payload of an external signer is omitted - it
109
+ * is not material to the review card and stays opaque to keep the
110
+ * description focused on what the human has to recognise. */
111
+ signers: Array<{
112
+ kind: 'delegated';
113
+ address: string;
114
+ } | {
115
+ kind: 'external';
116
+ verifier: string;
117
+ }>;
118
+ /** One entry per policy attached to the rule, decoded from the policies
119
+ * map (args[4]). The address is the map key; the kind + extras below
120
+ * describe the value. The interpreter policy also reports the
121
+ * sha256 of the predicate blob actually embedded in the XDR - so a
122
+ * mismatch between the wire bytes and the review card is detectable
123
+ * by reading `describes`. */
124
+ policies: Array<{
125
+ kind: 'interpreter';
126
+ address: string;
127
+ installNonce: number;
128
+ predicateHash: string;
129
+ predicateSha256OfEmbeddedBytes: string;
130
+ } | {
131
+ kind: 'oz_builtin';
132
+ address: string;
133
+ primitive: 'spending_limit' | 'simple_threshold' | 'weighted_threshold';
134
+ }>;
135
+ /** The install nonce, decoded from the interpreter policy's
136
+ * `install_nonce` field. Echoed at the top level for reviewer convenience;
137
+ * the per-policy entry is the source of truth. */
138
+ installNonce: number;
139
+ }
140
+ /** Output of the install-policy build. The unsigned XDR is the wallet's
141
+ * input; the captured auth nonce + invocation root make the response
142
+ * self-describing for callers that want to inspect what they signed. */
143
+ export interface BuildInstallPolicyResult {
144
+ /** Unsigned Soroban transaction envelope, base64 XDR. */
145
+ unsignedXdr: string;
146
+ /** Smart account contract address (echo). */
147
+ smartAccount: string;
148
+ /** Source account (echo) - the address that must sign. */
149
+ sourceAccount: string;
150
+ /** The host-call target + fn name. Call 2 (interpreter.install) is NOT
151
+ * emitted here; the account assigns the rule id in call 1 and call 2
152
+ * needs that id to bind its `context_rule_ids`. See `followUp` below. */
153
+ call: {
154
+ contract: string;
155
+ fn: 'add_context_rule';
156
+ };
157
+ /** The follow-up call the caller MUST issue after this one confirms.
158
+ * The rule id the account assigns in call 1 is required to bind the
159
+ * interpreter's `install` auth context. */
160
+ followUp: {
161
+ contract: 'interpreter';
162
+ fn: 'install';
163
+ requiresRuleIdFromCallOne: true;
164
+ hint: string;
165
+ };
166
+ /** Human-readable description of the install call, decoded FROM the
167
+ * built unsigned XDR (not from the input args). The wallet signature
168
+ * binds to bytes; the review card has to bind to the same bytes, so
169
+ * this is the only safe source. */
170
+ describes: InstallCallDescribes;
171
+ /** The auth nonce the host assigned to this call (snapshot). */
172
+ authNonce: string;
173
+ /** The ledger sequence + window the auth entry expires at. */
174
+ authValidUntilLedger: number;
175
+ /** The captured rootInvocation so a downstream caller can verify the
176
+ * signature payload matches the one the host expects. */
177
+ rootInvocationXdr: string;
178
+ }
179
+ /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
180
+ * The output XDR is signed by the wallet, not by us. */
181
+ export declare function buildInstallPolicyXdr(args: BuildInstallPolicyArgs): Promise<BuildInstallPolicyResult>;
182
+ /** Build an unsigned XDR for `account.remove_context_rule(ruleId)`. The
183
+ * smart account itself handles uninstalling each attached policy (calling
184
+ * `interpreter.uninstall` for the interpreter policy); this builder only
185
+ * emits the account-side removal call.
186
+ *
187
+ * Who may authorise it is the ACCOUNT's decision, and the account's source is
188
+ * not in this repo. The interpreter's own `uninstall` is master-gated, but
189
+ * that is a different entry point from this one, so do not restate it as the
190
+ * rule for this call. What is proven on testnet is that the account's
191
+ * deployer can revoke. This builder does not pre-check the signer: it would
192
+ * be guessing at a contract it cannot read, and the chain is the authority. */
193
+ export declare function buildRevokePolicyXdr(args: {
194
+ smartAccount: string;
195
+ sourceAccount: string;
196
+ ruleId: number;
197
+ networkPassphrase: string;
198
+ rpc: InstallRpcClient;
199
+ baseFee?: number;
200
+ authValidUntilLedgers?: number;
201
+ }): Promise<BuildRevokePolicyResult>;
202
+ export interface BuildRevokePolicyResult {
203
+ unsignedXdr: string;
204
+ smartAccount: string;
205
+ sourceAccount: string;
206
+ call: {
207
+ contract: string;
208
+ fn: 'remove_context_rule';
209
+ ruleId: number;
210
+ };
211
+ authNonce: string;
212
+ authValidUntilLedger: number;
213
+ rootInvocationXdr: string;
214
+ }
215
+ /** Re-export so the run-layer does not need to import from
216
+ * build-add-context-rule.ts (keeps the install/ -> install/ dependency
217
+ * direction intact). */
218
+ export { Contract, DEFAULT_GRAMMAR_VERSION };
@@ -0,0 +1,427 @@
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
+ import { createHash } from 'node:crypto';
20
+ import { Address, BASE_FEE, Contract, Keypair, Operation, rpc, scValToBigInt, TransactionBuilder, xdr, } from '@stellar/stellar-sdk';
21
+ import { buildAddContextRuleArgs, DEFAULT_GRAMMAR_VERSION } from "./build-add-context-rule.js";
22
+ /** Convert a real rpc.Server into the InstallRpcClient surface. The
23
+ * `getContractVersion` lookup uses `simulateTransaction` against
24
+ * `contract.call('grammar_version')` and decodes the returned u32.
25
+ *
26
+ * The passphrase is a REQUIRED argument rather than something read off the
27
+ * server: `rpc.Server` does not carry one, so reaching for
28
+ * `server.networkPassphrase` yielded a non-string and every version probe
29
+ * died with "Invalid passphrase provided to Transaction". The caller knows
30
+ * which network it dialled; make it say so. */
31
+ export function rpcClientFromServer(server, networkPassphrase) {
32
+ return {
33
+ getAccount: (address) => server.getAccount(address),
34
+ simulateTransaction: (tx) => server.simulateTransaction(tx),
35
+ getLatestLedger: () => server.getLatestLedger(),
36
+ async getContractVersion(address) {
37
+ // The source account is constructed locally rather than fetched. This is
38
+ // a read-only simulation, so the sequence number is never checked, and a
39
+ // random key does NOT exist on chain - asking the network for it returns
40
+ // 404 and the version probe fails for a reason that has nothing to do
41
+ // with the contract being probed.
42
+ const account = new Account(Keypair.random().publicKey(), '0');
43
+ const tx = new TransactionBuilder(account, {
44
+ fee: BASE_FEE,
45
+ networkPassphrase,
46
+ })
47
+ .addOperation(new Contract(address).call('grammar_version'))
48
+ .setTimeout(30)
49
+ .build();
50
+ const sim = await server.simulateTransaction(tx);
51
+ if (rpc.Api.isSimulationError(sim)) {
52
+ throw new Error(`getContractVersion: simulateTransaction failed: ${sim.error}`);
53
+ }
54
+ if (!sim.result?.retval) {
55
+ throw new Error('getContractVersion: grammar_version() returned no value');
56
+ }
57
+ const native = scValToBigInt(sim.result.retval);
58
+ if (typeof native !== 'bigint') {
59
+ throw new Error('getContractVersion: grammar_version() did not return an integer');
60
+ }
61
+ return Number(native);
62
+ },
63
+ };
64
+ }
65
+ const FOLLOWUP_HINT = '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.';
66
+ /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
67
+ * The output XDR is signed by the wallet, not by us. */
68
+ export async function buildInstallPolicyXdr(args) {
69
+ // 1. Pure arg encoding first - fail closed on any limit / shape problem
70
+ // BEFORE we burn a network round-trip on a malformed call.
71
+ const callArgs = buildAddContextRuleArgs({
72
+ contextRuleType: args.rule.contextRuleType,
73
+ name: args.rule.name,
74
+ validUntilLedger: args.rule.validUntilLedger,
75
+ signers: args.rule.signers,
76
+ policies: args.rule.policies.map(adaptPolicyRef),
77
+ }, {
78
+ signers: args.rule.signers,
79
+ policies: args.rule.policies.map(adaptPolicyRef),
80
+ installNonce: args.installNonce,
81
+ encodedPredicate: args.encodedPredicate,
82
+ predicateHash: args.predicateHash,
83
+ ...(args.oracleParams ? { oracleParams: args.oracleParams } : {}),
84
+ ...(args.grammarVersion !== undefined ? { grammarVersion: args.grammarVersion } : {}),
85
+ });
86
+ // 2. Network pass: source account sequence + latest ledger.
87
+ const source = await args.rpc.getAccount(args.sourceAccount);
88
+ const latest = await args.rpc.getLatestLedger();
89
+ const validUntilLedger = latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS);
90
+ // 3. Build the unsigned transaction envelope with the bare host call.
91
+ const op = Operation.invokeHostFunction({
92
+ func: xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
93
+ contractAddress: new Address(args.smartAccount).toScAddress(),
94
+ functionName: 'add_context_rule',
95
+ args: [...callArgs],
96
+ })),
97
+ auth: [],
98
+ });
99
+ const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE;
100
+ const tx = buildUnsignedTx({
101
+ sourceAccount: args.sourceAccount,
102
+ sequence: source.sequenceNumber(),
103
+ fee: baseFee,
104
+ networkPassphrase: args.networkPassphrase,
105
+ op,
106
+ });
107
+ // 4. Simulation pass. Returns the auth nonce + rootInvocation the wallet
108
+ // signature binds to. We do NOT add the AuthPayload here - the wallet
109
+ // injects auth entries using its own __check_auth flow.
110
+ const recorded = await args.rpc.simulateTransaction(tx);
111
+ if (rpc.Api.isSimulationError(recorded)) {
112
+ // Short, stable reason. The full `simulateTransaction` error (which
113
+ // carries host + URL detail) stays in the SDK's own logs - never
114
+ // reflected back into a user-facing message where it would
115
+ // reconnoitre the RPC.
116
+ throw new Error('install_policy: simulateTransaction failed');
117
+ }
118
+ const original = (recorded.result?.auth ?? []).find((entry) => entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
119
+ Address.fromScAddress(entry.credentials().address().address()).toString() ===
120
+ args.smartAccount);
121
+ if (!original) {
122
+ throw new Error(`install_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`);
123
+ }
124
+ // 5. Decode the structured description FROM the unsigned XDR. The human
125
+ // approval binds to the bytes the wallet will sign; deriving this from
126
+ // the input args would re-describe the caller's intent rather than the
127
+ // transaction. Re-parse the envelope we just built so the description
128
+ // reflects what the wallet actually signs, not what we thought it would.
129
+ const describes = decodeInstallCallDescribes(tx, op, args.installNonce);
130
+ return {
131
+ unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
132
+ smartAccount: args.smartAccount,
133
+ sourceAccount: args.sourceAccount,
134
+ call: { contract: args.smartAccount, fn: 'add_context_rule' },
135
+ followUp: {
136
+ contract: 'interpreter',
137
+ fn: 'install',
138
+ requiresRuleIdFromCallOne: true,
139
+ hint: FOLLOWUP_HINT,
140
+ },
141
+ describes,
142
+ authNonce: original.credentials().address().nonce().toString(),
143
+ authValidUntilLedger: validUntilLedger,
144
+ rootInvocationXdr: original.rootInvocation().toXDR().toString('base64'),
145
+ };
146
+ }
147
+ /** Build an unsigned XDR for `account.remove_context_rule(ruleId)`. The
148
+ * smart account itself handles uninstalling each attached policy (calling
149
+ * `interpreter.uninstall` for the interpreter policy); this builder only
150
+ * emits the account-side removal call.
151
+ *
152
+ * Who may authorise it is the ACCOUNT's decision, and the account's source is
153
+ * not in this repo. The interpreter's own `uninstall` is master-gated, but
154
+ * that is a different entry point from this one, so do not restate it as the
155
+ * rule for this call. What is proven on testnet is that the account's
156
+ * deployer can revoke. This builder does not pre-check the signer: it would
157
+ * be guessing at a contract it cannot read, and the chain is the authority. */
158
+ export async function buildRevokePolicyXdr(args) {
159
+ const source = await args.rpc.getAccount(args.sourceAccount);
160
+ const latest = await args.rpc.getLatestLedger();
161
+ const validUntilLedger = latest.sequence + (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_LEDGERS);
162
+ const op = Operation.invokeHostFunction({
163
+ func: xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
164
+ contractAddress: new Address(args.smartAccount).toScAddress(),
165
+ functionName: 'remove_context_rule',
166
+ args: [xdr.ScVal.scvU32(args.ruleId)],
167
+ })),
168
+ auth: [],
169
+ });
170
+ const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE;
171
+ const tx = buildUnsignedTx({
172
+ sourceAccount: args.sourceAccount,
173
+ sequence: source.sequenceNumber(),
174
+ fee: baseFee,
175
+ networkPassphrase: args.networkPassphrase,
176
+ op,
177
+ });
178
+ const recorded = await args.rpc.simulateTransaction(tx);
179
+ if (rpc.Api.isSimulationError(recorded)) {
180
+ // Short, stable reason. The full `simulateTransaction` error (which
181
+ // carries host + URL detail) stays in the SDK's own logs - never
182
+ // reflected back into a user-facing message where it would
183
+ // reconnoitre the RPC.
184
+ throw new Error('revoke_policy: simulateTransaction failed');
185
+ }
186
+ const original = (recorded.result?.auth ?? []).find((entry) => entry.credentials().switch().name === 'sorobanCredentialsAddress' &&
187
+ Address.fromScAddress(entry.credentials().address().address()).toString() ===
188
+ args.smartAccount);
189
+ if (!original) {
190
+ throw new Error(`revoke_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`);
191
+ }
192
+ return {
193
+ unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
194
+ smartAccount: args.smartAccount,
195
+ sourceAccount: args.sourceAccount,
196
+ call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
197
+ authNonce: original.credentials().address().nonce().toString(),
198
+ authValidUntilLedger: validUntilLedger,
199
+ rootInvocationXdr: original.rootInvocation().toXDR().toString('base64'),
200
+ };
201
+ }
202
+ /** ~25 minutes at 5s/ledger. */
203
+ const DEFAULT_AUTH_VALID_LEDGERS = 300;
204
+ // ---- internals ----
205
+ /** Adapter: turn the install-policy wire `PolicyRef` shape into the core
206
+ * `PolicyRef` shape `buildAddContextRuleArgs` expects. Keeps the wire
207
+ * schema hand-rolled + flat (the strict union would need a recursive
208
+ * schema) while delegating to the proven encoder for the actual bytes. */
209
+ function adaptPolicyRef(p) {
210
+ if (p.kind === 'interpreter') {
211
+ return {
212
+ kind: 'interpreter',
213
+ interpreterAddress: p.interpreterAddress,
214
+ predicateBlobBase64: p.predicateBlobBase64,
215
+ };
216
+ }
217
+ return {
218
+ kind: 'oz_builtin',
219
+ instanceAddress: p.instanceAddress,
220
+ primitive: p.primitive,
221
+ };
222
+ }
223
+ /** Build an unsigned Soroban transaction envelope. The `sequence` is
224
+ * whatever `getAccount().sequenceNumber()` returned (a string of digits
225
+ * the SDK accepts). No signing. The returned Transaction is what
226
+ * `simulateTransaction` accepts. */
227
+ function buildUnsignedTx(args) {
228
+ const account = new Account(args.sourceAccount, args.sequence);
229
+ const tx = new TransactionBuilder(account, {
230
+ fee: args.fee,
231
+ networkPassphrase: args.networkPassphrase,
232
+ })
233
+ .addOperation(args.op)
234
+ .setTimeout(0)
235
+ .build();
236
+ // `TransactionBuilder.build()` returns a Transaction whose envelope
237
+ // has empty signatures. The wallet re-reads the unsigned XDR, appends
238
+ // its signature, and broadcasts. We do NOT sign here.
239
+ return tx;
240
+ }
241
+ /** Decode the install call's structured fields directly out of the
242
+ * built (transaction, operation) pair. The bytes are the source of
243
+ * truth: a human reviewing the install wants to see what the wallet
244
+ * will actually sign, not what we think it will. The decode is
245
+ * deliberately strict - any shape we did not build ourselves throws
246
+ * a ToolError, since that would mean the XDR was tampered with (or
247
+ * the encoder changed and this descriptor lags).
248
+ *
249
+ * `expectedInstallNonce` is a fallback for the rare case where the
250
+ * install has no interpreter policy (no `install_nonce` to read); the
251
+ * caller already knows it. */
252
+ function decodeInstallCallDescribes(tx, op, expectedInstallNonce) {
253
+ const hostFn = op.body().invokeHostFunctionOp()?.hostFunction();
254
+ if (!hostFn || hostFn.switch().name !== 'hostFunctionTypeInvokeContract') {
255
+ throw new Error('install_policy: built op is not an invokeHostFunction(invokeContract(...)) call');
256
+ }
257
+ const invokeArgs = hostFn.invokeContract();
258
+ if (!invokeArgs) {
259
+ throw new Error('install_policy: built op has no InvokeContractArgs payload');
260
+ }
261
+ const targetContract = Address.fromScAddress(invokeArgs.contractAddress()).toString();
262
+ const fnName = invokeArgs.functionName().toString();
263
+ if (fnName !== 'add_context_rule') {
264
+ throw new Error(`install_policy: built op fn name is "${fnName}", expected "add_context_rule"`);
265
+ }
266
+ const scvArgs = invokeArgs.args();
267
+ if (scvArgs.length !== 5) {
268
+ throw new Error(`install_policy: built op has ${scvArgs.length} args, expected 5 (context_type, name, valid_until, signers, policies)`);
269
+ }
270
+ const contextType = scvArgs[0];
271
+ const nameScv = scvArgs[1];
272
+ const validUntilScv = scvArgs[2];
273
+ const signersScv = scvArgs[3];
274
+ const policiesScv = scvArgs[4];
275
+ // The length check above pins these as defined; the local references
276
+ // satisfy noUncheckedIndexedAccess on the accessors below.
277
+ if (!nameScv || !validUntilScv || !signersScv || !policiesScv || !contextType) {
278
+ throw new Error('install_policy: built op args include an undefined slot despite length check');
279
+ }
280
+ // name
281
+ if (nameScv.switch().name !== 'scvString') {
282
+ throw new Error('install_policy: args[1] (name) is not an ScVal::String');
283
+ }
284
+ const ruleName = nameScv.str().toString();
285
+ // valid_until (Option<u32> -> void when absent)
286
+ let validUntilLedger;
287
+ if (validUntilScv.switch().name === 'scvVoid') {
288
+ validUntilLedger = null;
289
+ }
290
+ else if (validUntilScv.switch().name === 'scvU32') {
291
+ validUntilLedger = validUntilScv.u32();
292
+ }
293
+ else {
294
+ throw new Error('install_policy: args[2] (valid_until) is neither void nor u32');
295
+ }
296
+ // signers (Vec<Vec<Symbol, Address[, Bytes]>>)
297
+ if (signersScv.switch().name !== 'scvVec') {
298
+ throw new Error('install_policy: args[3] (signers) is not an ScVal::Vec');
299
+ }
300
+ const signers = [];
301
+ for (const signerScv of signersScv.vec() ?? []) {
302
+ if (signerScv.switch().name !== 'scvVec') {
303
+ throw new Error('install_policy: a signer entry is not an ScVal::Vec');
304
+ }
305
+ const tuple = signerScv.vec() ?? [];
306
+ const tag = tuple[0]?.sym().toString();
307
+ const inner = tuple[1];
308
+ if (tag === 'Delegated') {
309
+ if (!inner || inner.switch().name !== 'scvAddress') {
310
+ throw new Error('install_policy: a Delegated signer is missing its Address');
311
+ }
312
+ signers.push({
313
+ kind: 'delegated',
314
+ address: Address.fromScAddress(inner.address()).toString(),
315
+ });
316
+ continue;
317
+ }
318
+ if (tag === 'External') {
319
+ if (!inner || inner.switch().name !== 'scvAddress') {
320
+ throw new Error('install_policy: an External signer is missing its verifier Address');
321
+ }
322
+ signers.push({
323
+ kind: 'external',
324
+ verifier: Address.fromScAddress(inner.address()).toString(),
325
+ });
326
+ continue;
327
+ }
328
+ throw new Error(`install_policy: signer tag "${tag ?? '<missing>'}" is not Delegated|External`);
329
+ }
330
+ // policies (Map<Address, Val>). Re-derive an interpreter-vs-OZ primitive
331
+ // classification by inspecting the value's map keys (PolicyInstallParams
332
+ // starts with `grammar_version`/`install_nonce`/`predicate`; OZ primitive
333
+ // params start with `spending_limit`/`threshold`/`period_ledgers`).
334
+ if (policiesScv.switch().name !== 'scvMap') {
335
+ throw new Error('install_policy: args[4] (policies) is not an ScVal::Map');
336
+ }
337
+ const policies = [];
338
+ let observedInstallNonce = null;
339
+ for (const entry of policiesScv.map() ?? []) {
340
+ const key = entry.key();
341
+ if (key.switch().name !== 'scvAddress') {
342
+ throw new Error('install_policy: a policies map entry has a non-Address key');
343
+ }
344
+ const address = Address.fromScAddress(key.address()).toString();
345
+ const val = entry.val();
346
+ if (val.switch().name !== 'scvMap') {
347
+ throw new Error(`install_policy: policies[${address}] value is not an ScVal::Map (got ${val.switch().name})`);
348
+ }
349
+ const fields = new Map();
350
+ for (const inner of val.map() ?? []) {
351
+ const fk = inner.key();
352
+ if (fk.switch().name !== 'scvSymbol') {
353
+ throw new Error(`install_policy: policies[${address}] field key is not an ScVal::Symbol`);
354
+ }
355
+ fields.set(fk.sym().toString(), inner.val());
356
+ }
357
+ // Interpreter policy fields carry grammar_version/install_nonce/predicate.
358
+ // OZ primitives carry spending_limit/period_ledgers/threshold/signers.
359
+ if (fields.has('predicate') || fields.has('grammar_version') || fields.has('install_nonce')) {
360
+ const installNonceScv = fields.get('install_nonce');
361
+ if (!installNonceScv || installNonceScv.switch().name !== 'scvU32') {
362
+ throw new Error(`install_policy: interpreter policy ${address} is missing a u32 install_nonce`);
363
+ }
364
+ const installNonce = installNonceScv.u32();
365
+ const predicateScv = fields.get('predicate');
366
+ if (!predicateScv || predicateScv.switch().name !== 'scvBytes') {
367
+ throw new Error(`install_policy: interpreter policy ${address} is missing its bytes predicate`);
368
+ }
369
+ const predicateBytes = Buffer.from(predicateScv.bytes());
370
+ const predicateSha256OfEmbeddedBytes = createHash('sha256')
371
+ .update(predicateBytes)
372
+ .digest('hex');
373
+ const predicateHashScv = fields.get('predicate_hash');
374
+ const predicateHash = predicateHashScv && predicateHashScv.switch().name === 'scvBytes'
375
+ ? Buffer.from(predicateHashScv.bytes()).toString('hex')
376
+ : '';
377
+ policies.push({
378
+ kind: 'interpreter',
379
+ address,
380
+ installNonce,
381
+ predicateHash,
382
+ predicateSha256OfEmbeddedBytes,
383
+ });
384
+ observedInstallNonce = installNonce;
385
+ continue;
386
+ }
387
+ // OZ built-in primitive. Distinguish by the parameter shape we
388
+ // emitted; matching one of the three primitives exactly pins the
389
+ // kind we built.
390
+ if (fields.has('spending_limit') && fields.has('period_ledgers')) {
391
+ policies.push({ kind: 'oz_builtin', address, primitive: 'spending_limit' });
392
+ continue;
393
+ }
394
+ if (fields.has('threshold') && fields.has('signer_weights')) {
395
+ policies.push({ kind: 'oz_builtin', address, primitive: 'weighted_threshold' });
396
+ continue;
397
+ }
398
+ if (fields.has('threshold')) {
399
+ policies.push({ kind: 'oz_builtin', address, primitive: 'simple_threshold' });
400
+ continue;
401
+ }
402
+ throw new Error(`install_policy: policies[${address}] value has an unknown field set; the encoder may have drifted`);
403
+ }
404
+ // `observedInstallNonce` is the nonce baked into whichever interpreter
405
+ // policy is present; when none, fall back to the caller-supplied value.
406
+ const installNonce = observedInstallNonce ?? expectedInstallNonce;
407
+ // Touch `tx` so the linter does not flag it as unused - the parameter
408
+ // documents that the caller built this Transaction; we re-parse the
409
+ // operation off it as a sanity check.
410
+ void tx;
411
+ return {
412
+ targetContract,
413
+ fnName: 'add_context_rule',
414
+ ruleName,
415
+ validUntilLedger,
416
+ signers,
417
+ policies,
418
+ installNonce,
419
+ };
420
+ }
421
+ /** Re-export so the run-layer does not need to import from
422
+ * build-add-context-rule.ts (keeps the install/ -> install/ dependency
423
+ * direction intact). */
424
+ export { Contract, DEFAULT_GRAMMAR_VERSION };
425
+ // Local re-import to avoid pulling the class from the SDK module path
426
+ // at the top of the file (avoids the unused-import lint).
427
+ import { Account } from '@stellar/stellar-sdk';
@@ -0,0 +1,29 @@
1
+ import type { Network } from '../types.ts';
2
+ export interface InterpreterInfo {
3
+ /** Pinned interpreter contract address. */
4
+ pinnedAddress: string;
5
+ /** Pinned grammar version (matches SELF_VERSION in version.rs). */
6
+ pinnedGrammarVersion: number;
7
+ /** Pinned wasm sha256 (hex). */
8
+ pinnedWasmHash: string;
9
+ /** Network this pin applies to (the address + hash are network-scoped). */
10
+ network: Network;
11
+ /** Present only when the caller supplied a live `deployedGrammarVersion`. */
12
+ deployedGrammarVersion?: number;
13
+ /** True when `deployedGrammarVersion` matches `pinnedGrammarVersion`.
14
+ * Absent when no live verification was performed. */
15
+ liveMatchesPin?: boolean;
16
+ }
17
+ /** Build the interpreter-info response. When `deployedGrammarVersion` is
18
+ * supplied (after a live RPC `grammar_version()` call by the run layer),
19
+ * compares it to the pin and sets `liveMatchesPin`. When absent, returns
20
+ * the pin alone. */
21
+ export declare function getInterpreterInfo(args: {
22
+ pinnedAddress: string;
23
+ pinnedGrammarVersion: number;
24
+ pinnedWasmHash: string;
25
+ network: Network;
26
+ /** When supplied, the u32 returned by the live contract's
27
+ * `grammar_version()` RPC call. */
28
+ deployedGrammarVersion?: number;
29
+ }): InterpreterInfo;