@crediolabs/policy-synth 0.5.2 → 0.5.3

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.
@@ -20,6 +20,10 @@ export interface ComposeUserResponses {
20
20
  export interface ComposeOptions {
21
21
  network: Network;
22
22
  userResponses?: ComposeUserResponses;
23
+ /** The smart account this rule will be installed on, when known. Used only
24
+ * to detect self-scope (see the warning below `scopeContract` is set) -
25
+ * composition proceeds identically when this is omitted. */
26
+ smartAccountAddress?: string;
23
27
  }
24
28
  /** One composed rule: what the call must be scoped to, the constraints on it,
25
29
  * and how long the resulting context rule lives. The constraints are already
@@ -2,9 +2,12 @@
2
2
  //
3
3
  // Fail-closed composition rules:
4
4
  // - unknown top-level protocol (registry.identifyProtocol returns null) ->
5
- // emit no constraint from the spend; scope is kept (contract + method) and
6
- // every inferred bound surfaces as a descriptive warning. An unrecognised
7
- // call never compiles to a permissive policy.
5
+ // no ABI means no argument can be singled out by role (recipient, spender,
6
+ // etc.), so a spend is never capped and no argument is guessed at; every
7
+ // address argument the call actually carries is pinned to its observed
8
+ // value instead (AC-33.19), and every other inferred bound surfaces as a
9
+ // descriptive warning. An unrecognised call never compiles to a
10
+ // permissive policy.
8
11
  // - a spend cap is emitted ONLY when the caller supplies `limitAmount` AND
9
12
  // the call carries an amount argument to bind it to. A single recorded
10
13
  // spend does NOT authorise that amount on every call, so the observed
@@ -80,9 +83,8 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
80
83
  }
81
84
  }
82
85
  // Observed recipient allowlist (SEP-41) is a real, recorded constraint the
83
- // interpreter adapter lowers to an `in` predicate. Unknown protocols emit
84
- // nothing here. SoroSwap's swap recipient (arg[3]) is the source-of-truth for
85
- // the swapRecipientAllowlist surface.
86
+ // interpreter adapter lowers to an `in` predicate. SoroSwap's swap recipient
87
+ // (arg[3]) is the source-of-truth for the swapRecipientAllowlist surface.
86
88
  if (topLevel && protocol !== null) {
87
89
  // A SoroSwap input-amount cap binds the caller's limitAmount to the swap's
88
90
  // input-amount argument ONLY when no outgoing spend was detected for the
@@ -93,6 +95,41 @@ export function composeFromRecording(facts, scopeContract, topLevel, opts) {
93
95
  const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
94
96
  appendProtocolSpecificConstraints(interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap);
95
97
  }
98
+ else if (topLevel && protocol === null) {
99
+ // Unrecognised protocol: there is no published ABI, so no argument can be
100
+ // singled out as THE recipient - guessing an index risks pinning the
101
+ // wrong value (false security) or a value that means something else
102
+ // entirely for a different contract sharing the same shape. What the
103
+ // recording DOES evidence, without any interpretation, is every address
104
+ // literally carried by the call. AC-33.19: a value the recording
105
+ // evidences is pinned by default, never left open because its semantic
106
+ // role is unrecognised - so every address argument is pinned to its
107
+ // observed value, not just the one a known ABI would call "to".
108
+ const addressArgIndexes = topLevel.args.flatMap((arg, i) => (arg.type === 'address' ? [i] : []));
109
+ if (addressArgIndexes.length > 0) {
110
+ for (const i of addressArgIndexes) {
111
+ const arg = topLevel.args[i];
112
+ if (arg?.type !== 'address')
113
+ continue;
114
+ interpreterConstraints.push({
115
+ op: 'in',
116
+ needle: { kind: 'call_arg', index: i },
117
+ haystack: [{ kind: 'literal_address', value: arg.value }],
118
+ });
119
+ }
120
+ warnings.push(`unrecognised protocol: every address argument observed in the call (index ${addressArgIndexes.join(', ')}) was pinned to its recorded value - the ABI is unknown, so no single argument could be identified as the recipient specifically; read this pin with less confidence than a recognised protocol's`);
121
+ }
122
+ }
123
+ // Self-scope: a rule whose scope targets the smart account that will hold
124
+ // it lets the account authorise calls against its own governance surface
125
+ // (e.g. a recorded batch_add_signer against the account itself). Such a
126
+ // rule installs cleanly and then denies every OTHER call with OZ error
127
+ // #3002, bricking the account. Detected only when the caller supplies
128
+ // smartAccountAddress; without it the composer has nothing to compare
129
+ // scopeContract against, so composition proceeds unchanged.
130
+ if (opts.smartAccountAddress !== undefined && scopeContract === opts.smartAccountAddress) {
131
+ warnings.push(`scope.contract (${scopeContract}) is the smart account's own address: a rule scoping ${topLevel?.fn ?? 'this call'} to the account that will hold it lets the account govern its own governance surface, and denies every other call with OZ error #3002 once installed`);
132
+ }
96
133
  // The interpreter adapter lowers `scope.method` into a `call_fn == <method>`
97
134
  // predicate leaf.
98
135
  const scope = { contract: scopeContract };
@@ -148,6 +148,9 @@ function synthesizeFromRecordingInner(tx, opts) {
148
148
  const composeOpts = {
149
149
  network: opts.network,
150
150
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
151
+ ...(opts.interpreter?.smartAccountAddress !== undefined
152
+ ? { smartAccountAddress: opts.interpreter.smartAccountAddress }
153
+ : {}),
151
154
  };
152
155
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts);
153
156
  // --explain hook: capture the in-memory predicate tree + the real
@@ -20,6 +20,10 @@ export interface ComposeUserResponses {
20
20
  export interface ComposeOptions {
21
21
  network: Network;
22
22
  userResponses?: ComposeUserResponses;
23
+ /** The smart account this rule will be installed on, when known. Used only
24
+ * to detect self-scope (see the warning below `scopeContract` is set) -
25
+ * composition proceeds identically when this is omitted. */
26
+ smartAccountAddress?: string;
23
27
  }
24
28
  /** One composed rule: what the call must be scoped to, the constraints on it,
25
29
  * and how long the resulting context rule lives. The constraints are already
@@ -3,9 +3,12 @@
3
3
  //
4
4
  // Fail-closed composition rules:
5
5
  // - unknown top-level protocol (registry.identifyProtocol returns null) ->
6
- // emit no constraint from the spend; scope is kept (contract + method) and
7
- // every inferred bound surfaces as a descriptive warning. An unrecognised
8
- // call never compiles to a permissive policy.
6
+ // no ABI means no argument can be singled out by role (recipient, spender,
7
+ // etc.), so a spend is never capped and no argument is guessed at; every
8
+ // address argument the call actually carries is pinned to its observed
9
+ // value instead (AC-33.19), and every other inferred bound surfaces as a
10
+ // descriptive warning. An unrecognised call never compiles to a
11
+ // permissive policy.
9
12
  // - a spend cap is emitted ONLY when the caller supplies `limitAmount` AND
10
13
  // the call carries an amount argument to bind it to. A single recorded
11
14
  // spend does NOT authorise that amount on every call, so the observed
@@ -83,9 +86,8 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
83
86
  }
84
87
  }
85
88
  // Observed recipient allowlist (SEP-41) is a real, recorded constraint the
86
- // interpreter adapter lowers to an `in` predicate. Unknown protocols emit
87
- // nothing here. SoroSwap's swap recipient (arg[3]) is the source-of-truth for
88
- // the swapRecipientAllowlist surface.
89
+ // interpreter adapter lowers to an `in` predicate. SoroSwap's swap recipient
90
+ // (arg[3]) is the source-of-truth for the swapRecipientAllowlist surface.
89
91
  if (topLevel && protocol !== null) {
90
92
  // A SoroSwap input-amount cap binds the caller's limitAmount to the swap's
91
93
  // input-amount argument ONLY when no outgoing spend was detected for the
@@ -96,6 +98,41 @@ function composeFromRecording(facts, scopeContract, topLevel, opts) {
96
98
  const swapInputAmountCap = spendTokens.length === 0 ? limitAmount : undefined;
97
99
  appendProtocolSpecificConstraints(interpreterConstraints, warnings, ambiguities, facts, topLevel, protocol, opts.userResponses?.swapRecipientAllowlist, swapInputAmountCap);
98
100
  }
101
+ else if (topLevel && protocol === null) {
102
+ // Unrecognised protocol: there is no published ABI, so no argument can be
103
+ // singled out as THE recipient - guessing an index risks pinning the
104
+ // wrong value (false security) or a value that means something else
105
+ // entirely for a different contract sharing the same shape. What the
106
+ // recording DOES evidence, without any interpretation, is every address
107
+ // literally carried by the call. AC-33.19: a value the recording
108
+ // evidences is pinned by default, never left open because its semantic
109
+ // role is unrecognised - so every address argument is pinned to its
110
+ // observed value, not just the one a known ABI would call "to".
111
+ const addressArgIndexes = topLevel.args.flatMap((arg, i) => (arg.type === 'address' ? [i] : []));
112
+ if (addressArgIndexes.length > 0) {
113
+ for (const i of addressArgIndexes) {
114
+ const arg = topLevel.args[i];
115
+ if (arg?.type !== 'address')
116
+ continue;
117
+ interpreterConstraints.push({
118
+ op: 'in',
119
+ needle: { kind: 'call_arg', index: i },
120
+ haystack: [{ kind: 'literal_address', value: arg.value }],
121
+ });
122
+ }
123
+ warnings.push(`unrecognised protocol: every address argument observed in the call (index ${addressArgIndexes.join(', ')}) was pinned to its recorded value - the ABI is unknown, so no single argument could be identified as the recipient specifically; read this pin with less confidence than a recognised protocol's`);
124
+ }
125
+ }
126
+ // Self-scope: a rule whose scope targets the smart account that will hold
127
+ // it lets the account authorise calls against its own governance surface
128
+ // (e.g. a recorded batch_add_signer against the account itself). Such a
129
+ // rule installs cleanly and then denies every OTHER call with OZ error
130
+ // #3002, bricking the account. Detected only when the caller supplies
131
+ // smartAccountAddress; without it the composer has nothing to compare
132
+ // scopeContract against, so composition proceeds unchanged.
133
+ if (opts.smartAccountAddress !== undefined && scopeContract === opts.smartAccountAddress) {
134
+ warnings.push(`scope.contract (${scopeContract}) is the smart account's own address: a rule scoping ${topLevel?.fn ?? 'this call'} to the account that will hold it lets the account govern its own governance surface, and denies every other call with OZ error #3002 once installed`);
135
+ }
99
136
  // The interpreter adapter lowers `scope.method` into a `call_fn == <method>`
100
137
  // predicate leaf.
101
138
  const scope = { contract: scopeContract };
@@ -152,6 +152,9 @@ function synthesizeFromRecordingInner(tx, opts) {
152
152
  const composeOpts = {
153
153
  network: opts.network,
154
154
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
155
+ ...(opts.interpreter?.smartAccountAddress !== undefined
156
+ ? { smartAccountAddress: opts.interpreter.smartAccountAddress }
157
+ : {}),
155
158
  };
156
159
  const composed = (0, compose_from_recording_ts_1.composeFromRecording)(facts, scope.contract, topLevel, composeOpts);
157
160
  // --explain hook: capture the in-memory predicate tree + the real
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-synth",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "license": "MIT",
5
5
  "description": "Off-chain TypeScript synthesis core for the OZ Accounts Policy Builder. Records Soroban transactions, synthesises the minimal policy that permits exactly that flow, verifies it, and returns an unsigned install transaction.",
6
6
  "type": "module",
@@ -2,9 +2,12 @@
2
2
  //
3
3
  // Fail-closed composition rules:
4
4
  // - unknown top-level protocol (registry.identifyProtocol returns null) ->
5
- // emit no constraint from the spend; scope is kept (contract + method) and
6
- // every inferred bound surfaces as a descriptive warning. An unrecognised
7
- // call never compiles to a permissive policy.
5
+ // no ABI means no argument can be singled out by role (recipient, spender,
6
+ // etc.), so a spend is never capped and no argument is guessed at; every
7
+ // address argument the call actually carries is pinned to its observed
8
+ // value instead (AC-33.19), and every other inferred bound surfaces as a
9
+ // descriptive warning. An unrecognised call never compiles to a
10
+ // permissive policy.
8
11
  // - a spend cap is emitted ONLY when the caller supplies `limitAmount` AND
9
12
  // the call carries an amount argument to bind it to. A single recorded
10
13
  // spend does NOT authorise that amount on every call, so the observed
@@ -43,6 +46,10 @@ export interface ComposeUserResponses {
43
46
  export interface ComposeOptions {
44
47
  network: Network
45
48
  userResponses?: ComposeUserResponses
49
+ /** The smart account this rule will be installed on, when known. Used only
50
+ * to detect self-scope (see the warning below `scopeContract` is set) -
51
+ * composition proceeds identically when this is omitted. */
52
+ smartAccountAddress?: string
46
53
  }
47
54
 
48
55
  /** One composed rule: what the call must be scoped to, the constraints on it,
@@ -145,9 +152,8 @@ export function composeFromRecording(
145
152
  }
146
153
 
147
154
  // Observed recipient allowlist (SEP-41) is a real, recorded constraint the
148
- // interpreter adapter lowers to an `in` predicate. Unknown protocols emit
149
- // nothing here. SoroSwap's swap recipient (arg[3]) is the source-of-truth for
150
- // the swapRecipientAllowlist surface.
155
+ // interpreter adapter lowers to an `in` predicate. SoroSwap's swap recipient
156
+ // (arg[3]) is the source-of-truth for the swapRecipientAllowlist surface.
151
157
  if (topLevel && protocol !== null) {
152
158
  // A SoroSwap input-amount cap binds the caller's limitAmount to the swap's
153
159
  // input-amount argument ONLY when no outgoing spend was detected for the
@@ -166,6 +172,44 @@ export function composeFromRecording(
166
172
  opts.userResponses?.swapRecipientAllowlist,
167
173
  swapInputAmountCap
168
174
  )
175
+ } else if (topLevel && protocol === null) {
176
+ // Unrecognised protocol: there is no published ABI, so no argument can be
177
+ // singled out as THE recipient - guessing an index risks pinning the
178
+ // wrong value (false security) or a value that means something else
179
+ // entirely for a different contract sharing the same shape. What the
180
+ // recording DOES evidence, without any interpretation, is every address
181
+ // literally carried by the call. AC-33.19: a value the recording
182
+ // evidences is pinned by default, never left open because its semantic
183
+ // role is unrecognised - so every address argument is pinned to its
184
+ // observed value, not just the one a known ABI would call "to".
185
+ const addressArgIndexes = topLevel.args.flatMap((arg, i) => (arg.type === 'address' ? [i] : []))
186
+ if (addressArgIndexes.length > 0) {
187
+ for (const i of addressArgIndexes) {
188
+ const arg = topLevel.args[i]
189
+ if (arg?.type !== 'address') continue
190
+ interpreterConstraints.push({
191
+ op: 'in',
192
+ needle: { kind: 'call_arg', index: i },
193
+ haystack: [{ kind: 'literal_address', value: arg.value }],
194
+ })
195
+ }
196
+ warnings.push(
197
+ `unrecognised protocol: every address argument observed in the call (index ${addressArgIndexes.join(', ')}) was pinned to its recorded value - the ABI is unknown, so no single argument could be identified as the recipient specifically; read this pin with less confidence than a recognised protocol's`
198
+ )
199
+ }
200
+ }
201
+
202
+ // Self-scope: a rule whose scope targets the smart account that will hold
203
+ // it lets the account authorise calls against its own governance surface
204
+ // (e.g. a recorded batch_add_signer against the account itself). Such a
205
+ // rule installs cleanly and then denies every OTHER call with OZ error
206
+ // #3002, bricking the account. Detected only when the caller supplies
207
+ // smartAccountAddress; without it the composer has nothing to compare
208
+ // scopeContract against, so composition proceeds unchanged.
209
+ if (opts.smartAccountAddress !== undefined && scopeContract === opts.smartAccountAddress) {
210
+ warnings.push(
211
+ `scope.contract (${scopeContract}) is the smart account's own address: a rule scoping ${topLevel?.fn ?? 'this call'} to the account that will hold it lets the account govern its own governance surface, and denies every other call with OZ error #3002 once installed`
212
+ )
169
213
  }
170
214
 
171
215
  // The interpreter adapter lowers `scope.method` into a `call_fn == <method>`
@@ -234,6 +234,9 @@ function synthesizeFromRecordingInner(
234
234
  const composeOpts: ComposeOptions = {
235
235
  network: opts.network,
236
236
  ...(opts.userResponses !== undefined ? { userResponses: opts.userResponses } : {}),
237
+ ...(opts.interpreter?.smartAccountAddress !== undefined
238
+ ? { smartAccountAddress: opts.interpreter.smartAccountAddress }
239
+ : {}),
237
240
  }
238
241
  const composed = composeFromRecording(facts, scope.contract, topLevel, composeOpts)
239
242