@crediolabs/policy-synth 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -147,22 +147,20 @@ export interface BuildInstallPolicyResult {
147
147
  smartAccount: string;
148
148
  /** Source account (echo) - the address that must sign. */
149
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. */
150
+ /** The host-call target + fn name. This single call installs the policy
151
+ * outright: `add_context_rule` takes `policies` as a
152
+ * `Map<policy_address, install_param>`, and the account forwards each
153
+ * install_param to that policy. For an interpreter policy the param
154
+ * carries the predicate and its hash, so the interpreter stores the
155
+ * document as part of this transaction. There is no second call.
156
+ *
157
+ * Verified on testnet 2026-08-01: a rule created by this builder alone
158
+ * permits a matching operator call and denies a non-matching one with
159
+ * interpreter code #100 - not #206 MissingState. */
153
160
  call: {
154
161
  contract: string;
155
162
  fn: 'add_context_rule';
156
163
  };
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
164
  /** Human-readable description of the install call, decoded FROM the
167
165
  * built unsigned XDR (not from the input args). The wallet signature
168
166
  * binds to bytes; the review card has to bind to the same bytes, so
@@ -13,12 +13,16 @@
13
13
  // (b) key material the server does not have, so we ship the simpler ONE-CALL
14
14
  // shape. The wallet signature covers the change.
15
15
  //
16
- // `buildInstallPolicyXdr` returns CALL 1 ONLY. Call 2 (interpreter.install)
17
- // needs the rule id the account assigns in call 1, so it cannot be pre-built
18
- // and is documented in the response envelope under `followUp`.
16
+ // `buildInstallPolicyXdr` installs the policy in ONE call. `add_context_rule`
17
+ // takes `policies` as a `Map<policy_address, install_param>` and the account
18
+ // forwards each install_param to that policy, so the interpreter stores the
19
+ // predicate document as part of this same transaction. Earlier revisions
20
+ // documented a second `interpreter.install` call; that was wrong, and issuing
21
+ // it fails - the account re-enters the interpreter while it is mid-install.
19
22
  import { createHash } from 'node:crypto';
20
23
  import { Address, BASE_FEE, Contract, Keypair, Operation, rpc, scValToBigInt, TransactionBuilder, xdr, } from '@stellar/stellar-sdk';
21
24
  import { buildAddContextRuleArgs, DEFAULT_GRAMMAR_VERSION } from "./build-add-context-rule.js";
25
+ import { accountEntry, authDigest, authPayload, delegatedSignerEntry, signaturePayload, } from "./oz-auth.js";
22
26
  /** Convert a real rpc.Server into the InstallRpcClient surface. The
23
27
  * `getContractVersion` lookup uses `simulateTransaction` against
24
28
  * `contract.call('grammar_version')` and decodes the returned u32.
@@ -62,7 +66,6 @@ export function rpcClientFromServer(server, networkPassphrase) {
62
66
  },
63
67
  };
64
68
  }
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
69
  /** Build the unsigned transaction envelope for `account.add_context_rule(...)`.
67
70
  * The output XDR is signed by the wallet, not by us. */
68
71
  export async function buildInstallPolicyXdr(args) {
@@ -83,31 +86,28 @@ export async function buildInstallPolicyXdr(args) {
83
86
  ...(args.oracleParams ? { oracleParams: args.oracleParams } : {}),
84
87
  ...(args.grammarVersion !== undefined ? { grammarVersion: args.grammarVersion } : {}),
85
88
  });
86
- // 2. Network pass: source account sequence + latest ledger.
89
+ // 2. Fetch the source sequence, then build the recording transaction with a
90
+ // bare host call. The recording pass assigns the smart-account auth nonce
91
+ // and root invocation needed to construct OZ's AuthPayload.
87
92
  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
- });
93
+ const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
94
+ contractAddress: new Address(args.smartAccount).toScAddress(),
95
+ functionName: 'add_context_rule',
96
+ args: [...callArgs],
97
+ }));
98
+ const makeOperation = (auth = []) => Operation.invokeHostFunction({ func: hostFunction, auth });
99
99
  const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE;
100
- const tx = buildUnsignedTx({
100
+ const buildTx = (op) => buildUnsignedTx({
101
101
  sourceAccount: args.sourceAccount,
102
102
  sequence: source.sequenceNumber(),
103
103
  fee: baseFee,
104
104
  networkPassphrase: args.networkPassphrase,
105
105
  op,
106
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);
107
+ const recordingTx = buildTx(makeOperation());
108
+ // 3. Recording simulation: capture the smart account's nonce and invocation
109
+ // tree. Nothing is signed here.
110
+ const recorded = await args.rpc.simulateTransaction(recordingTx);
111
111
  if (rpc.Api.isSimulationError(recorded)) {
112
112
  // Short, stable reason. The full `simulateTransaction` error (which
113
113
  // carries host + URL detail) stays in the SDK's own logs - never
@@ -121,23 +121,35 @@ export async function buildInstallPolicyXdr(args) {
121
121
  if (!original) {
122
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
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);
124
+ // 4. `add_context_rule` is authorised by the deploy-time admin rule (rule 0).
125
+ // The delegated signer uses source-account credentials, so its signature
126
+ // bytes stay empty and the ordinary transaction-envelope signature covers
127
+ // the call when the consumer signs it.
128
+ const validUntilLedger = (await args.rpc.getLatestLedger()).sequence +
129
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS);
130
+ const contextRuleIds = [0];
131
+ const digest = authDigest(signaturePayload(args.networkPassphrase, original.credentials().address().nonce(), validUntilLedger, original.rootInvocation()), contextRuleIds);
132
+ const authEntries = [
133
+ accountEntry(original, validUntilLedger, authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))),
134
+ ...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
135
+ ];
136
+ // 5. Simulate again with the OZ entries already attached. The SDK preserves
137
+ // existing auth during assembly and adds the simulated Soroban footprint +
138
+ // resource fee, yielding a complete unsigned envelope.
139
+ const txWithAuth = buildTx(makeOperation(authEntries));
140
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth);
141
+ if (rpc.Api.isSimulationError(enforcing)) {
142
+ throw new Error('install_policy: auth simulateTransaction failed');
143
+ }
144
+ const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build();
145
+ // 6. Decode the structured description FROM the final assembled transaction.
146
+ // The human approval binds to the exact bytes the wallet will sign.
147
+ const describes = decodeInstallCallDescribes(finalTx, args.installNonce);
130
148
  return {
131
- unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
149
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
132
150
  smartAccount: args.smartAccount,
133
151
  sourceAccount: args.sourceAccount,
134
152
  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
153
  describes,
142
154
  authNonce: original.credentials().address().nonce().toString(),
143
155
  authValidUntilLedger: validUntilLedger,
@@ -157,25 +169,21 @@ export async function buildInstallPolicyXdr(args) {
157
169
  * be guessing at a contract it cannot read, and the chain is the authority. */
158
170
  export async function buildRevokePolicyXdr(args) {
159
171
  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
- });
172
+ const hostFunction = xdr.HostFunction.hostFunctionTypeInvokeContract(new xdr.InvokeContractArgs({
173
+ contractAddress: new Address(args.smartAccount).toScAddress(),
174
+ functionName: 'remove_context_rule',
175
+ args: [xdr.ScVal.scvU32(args.ruleId)],
176
+ }));
177
+ const makeOperation = (auth = []) => Operation.invokeHostFunction({ func: hostFunction, auth });
170
178
  const baseFee = args.baseFee !== undefined ? String(args.baseFee) : BASE_FEE;
171
- const tx = buildUnsignedTx({
179
+ const buildTx = (op) => buildUnsignedTx({
172
180
  sourceAccount: args.sourceAccount,
173
181
  sequence: source.sequenceNumber(),
174
182
  fee: baseFee,
175
183
  networkPassphrase: args.networkPassphrase,
176
184
  op,
177
185
  });
178
- const recorded = await args.rpc.simulateTransaction(tx);
186
+ const recorded = await args.rpc.simulateTransaction(buildTx(makeOperation()));
179
187
  if (rpc.Api.isSimulationError(recorded)) {
180
188
  // Short, stable reason. The full `simulateTransaction` error (which
181
189
  // carries host + URL detail) stays in the SDK's own logs - never
@@ -189,8 +197,27 @@ export async function buildRevokePolicyXdr(args) {
189
197
  if (!original) {
190
198
  throw new Error(`revoke_policy: no Soroban auth entry for smart account ${args.smartAccount}; this call does not route through the smart account`);
191
199
  }
200
+ // Removal is master-only, so the deploy-time admin rule authorises it. The
201
+ // recorded rootInvocation still includes remove_context_rule(ruleId), binding
202
+ // the auth payload to the exact rule being removed. As with install,
203
+ // source-account credentials carry the delegated signer entry and the
204
+ // consumer supplies only the ordinary envelope signature.
205
+ const validUntilLedger = (await args.rpc.getLatestLedger()).sequence +
206
+ (args.authValidUntilLedgers ?? DEFAULT_AUTH_VALID_UNTIL_LEDGERS);
207
+ const contextRuleIds = [0];
208
+ const digest = authDigest(signaturePayload(args.networkPassphrase, original.credentials().address().nonce(), validUntilLedger, original.rootInvocation()), contextRuleIds);
209
+ const authEntries = [
210
+ accountEntry(original, validUntilLedger, authPayload([args.sourceAccount], contextRuleIds, () => Buffer.alloc(0))),
211
+ ...contextRuleIds.map(() => delegatedSignerEntry(args.smartAccount, digest)),
212
+ ];
213
+ const txWithAuth = buildTx(makeOperation(authEntries));
214
+ const enforcing = await args.rpc.simulateTransaction(txWithAuth);
215
+ if (rpc.Api.isSimulationError(enforcing)) {
216
+ throw new Error('revoke_policy: auth simulateTransaction failed');
217
+ }
218
+ const finalTx = rpc.assembleTransaction(txWithAuth, enforcing).build();
192
219
  return {
193
- unsignedXdr: tx.toEnvelope().toXDR().toString('base64'),
220
+ unsignedXdr: finalTx.toEnvelope().toXDR().toString('base64'),
194
221
  smartAccount: args.smartAccount,
195
222
  sourceAccount: args.sourceAccount,
196
223
  call: { contract: args.smartAccount, fn: 'remove_context_rule', ruleId: args.ruleId },
@@ -200,7 +227,7 @@ export async function buildRevokePolicyXdr(args) {
200
227
  };
201
228
  }
202
229
  /** ~25 minutes at 5s/ledger. */
203
- const DEFAULT_AUTH_VALID_LEDGERS = 300;
230
+ const DEFAULT_AUTH_VALID_UNTIL_LEDGERS = 300;
204
231
  // ---- internals ----
205
232
  /** Adapter: turn the install-policy wire `PolicyRef` shape into the core
206
233
  * `PolicyRef` shape `buildAddContextRuleArgs` expects. Keeps the wire
@@ -238,20 +265,24 @@ function buildUnsignedTx(args) {
238
265
  // its signature, and broadcasts. We do NOT sign here.
239
266
  return tx;
240
267
  }
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
268
+ /** Decode the install call's structured fields directly out of the final
269
+ * assembled transaction. The bytes are the source of truth: a human reviewing
270
+ * the install wants to see what the wallet will actually sign, not what we
271
+ * think it will. The decode is deliberately strict - any shape we did not
272
+ * build ourselves throws, since that would mean the XDR was tampered with (or
247
273
  * the encoder changed and this descriptor lags).
248
274
  *
249
275
  * `expectedInstallNonce` is a fallback for the rare case where the
250
276
  * install has no interpreter policy (no `install_nonce` to read); the
251
277
  * caller already knows it. */
252
- function decodeInstallCallDescribes(tx, op, expectedInstallNonce) {
278
+ function decodeInstallCallDescribes(tx, expectedInstallNonce) {
279
+ const operations = tx.toEnvelope().v1().tx().operations();
280
+ if (operations.length !== 1 || !operations[0]) {
281
+ throw new Error(`install_policy: final transaction has ${operations.length} operations, expected 1`);
282
+ }
283
+ const op = operations[0];
253
284
  const hostFn = op.body().invokeHostFunctionOp()?.hostFunction();
254
- if (!hostFn || hostFn.switch().name !== 'hostFunctionTypeInvokeContract') {
285
+ if (hostFn?.switch().name !== 'hostFunctionTypeInvokeContract') {
255
286
  throw new Error('install_policy: built op is not an invokeHostFunction(invokeContract(...)) call');
256
287
  }
257
288
  const invokeArgs = hostFn.invokeContract();
@@ -404,10 +435,6 @@ function decodeInstallCallDescribes(tx, op, expectedInstallNonce) {
404
435
  // `observedInstallNonce` is the nonce baked into whichever interpreter
405
436
  // policy is present; when none, fall back to the caller-supplied value.
406
437
  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
438
  return {
412
439
  targetContract,
413
440
  fnName: 'add_context_rule',
@@ -0,0 +1,96 @@
1
+ import { Contract } from '@stellar/stellar-sdk';
2
+ import type { InstallRpcClient } from './build-install-policy.ts';
3
+ /** Inputs for the install-predicate build. */
4
+ export interface BuildInstallPredicateArgs {
5
+ /** The smart account contract address (C...). The interpreter hashes
6
+ * this rule's `signers` at install; we fetch it via `get_context_rule`
7
+ * on this address, not reconstruct it. */
8
+ smartAccount: string;
9
+ /** The signer that authorises the install (G... wallet). */
10
+ sourceAccount: string;
11
+ /** The rule id the account assigned in call 1. */
12
+ ruleId: number;
13
+ /** The interpreter contract address (C...). */
14
+ interpreterAddress: string;
15
+ /** Network passphrase - pins the hash the auth digest binds. */
16
+ networkPassphrase: string;
17
+ /** Already-encoded (base64) canonical ScVal of the predicate. */
18
+ encodedPredicate: string;
19
+ /** Hex sha256 of the canonical predicate XDR bytes. */
20
+ predicateHash: string;
21
+ /** Grammar version override; defaults to 1. */
22
+ grammarVersion?: number;
23
+ /** Base fee in stroops; defaults to BASE_FEE (100). */
24
+ baseFee?: number;
25
+ /** RPC client to use for the simulation pass; injected for tests. */
26
+ rpc: InstallRpcClient;
27
+ /** Ledger window (in ledgers) for the auth entry's `validUntil`. */
28
+ authValidUntilLedgers?: number;
29
+ }
30
+ /** Human-readable description of the install-predicate call, decoded FROM
31
+ * the built XDR (not from the input args). The wallet signature binds
32
+ * to bytes; the review card has to bind to the same bytes, so this is
33
+ * the only safe source.
34
+ *
35
+ * `ruleId` is NOT decoded from the XDR - it lives inside
36
+ * `raw_context_rule`, which is passed through verbatim. Decoding it
37
+ * here would re-introduce the client-side reconstruction the file
38
+ * header warns against. The top-level `BuildInstallPredicateResult`
39
+ * echoes the caller-supplied rule id instead. */
40
+ export interface InstallPredicateCallDescribes {
41
+ /** The interpreter that will hold the predicate. */
42
+ targetContract: string;
43
+ /** The fn name on the target (always `install` today). */
44
+ fnName: 'install';
45
+ /** Grammar version the interpreter enforces; read off the wire. */
46
+ grammarVersion: number;
47
+ /** Per-rule install nonce; read off the wire. */
48
+ installNonce: number;
49
+ /** Hex sha256 of the encoded predicate blob; read off the wire. */
50
+ predicateHash: string;
51
+ /** Hex sha256 of the predicate bytes embedded in the params map. The
52
+ * two values MUST match; a difference is a wire tampering signal. */
53
+ predicateSha256OfEmbeddedBytes: string;
54
+ /** The smart account address encoded in args[2], read off the wire. */
55
+ smartAccountOnWire: string;
56
+ }
57
+ /** Output of the install-predicate build. The unsigned XDR is the
58
+ * wallet's input; the wallet signs it (single envelope signature) and
59
+ * submits. The auth tree matches install_policy: account entry
60
+ * carrying OZ's AuthPayload + a source-account delegated signer entry.
61
+ * Both are covered by the envelope signature. */
62
+ export interface BuildInstallPredicateResult {
63
+ /** Unsigned Soroban transaction envelope, base64 XDR. */
64
+ unsignedXdr: string;
65
+ /** Smart account contract address (echo). */
66
+ smartAccount: string;
67
+ /** Source account (echo) - the address that signs the envelope. */
68
+ sourceAccount: string;
69
+ /** The interpreter contract address (echo). */
70
+ interpreterAddress: string;
71
+ /** The rule id the account assigned in call 1 (echo). */
72
+ ruleId: number;
73
+ /** The host-call target + fn name. */
74
+ call: {
75
+ contract: string;
76
+ fn: 'install';
77
+ };
78
+ /** The smart-account auth nonce (snapshot). */
79
+ authNonce: string;
80
+ /** The ledger sequence + window the auth entry expires at. */
81
+ authValidUntilLedger: number;
82
+ /** The captured rootInvocation for the smart-account entry. */
83
+ rootInvocationXdr: string;
84
+ /** Human-readable description of the install call, decoded FROM the
85
+ * built unsigned XDR (not from the input args). The wallet signature
86
+ * binds to bytes; the review card has to bind to the same bytes, so
87
+ * this is the only safe source. */
88
+ describes: InstallPredicateCallDescribes;
89
+ }
90
+ /** Build the unsigned transaction envelope for
91
+ * `interpreter.install(params, raw_context_rule, smart_account)`. The
92
+ * output XDR is signed by the wallet, not by us. */
93
+ export declare function buildInstallPredicateXdr(args: BuildInstallPredicateArgs): Promise<BuildInstallPredicateResult>;
94
+ /** Re-export `Contract` so the run-layer does not need to import the
95
+ * SDK directly. Mirrors the build-install-policy.ts convention. */
96
+ export { Contract };