@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.
- package/dist/synth/compose-from-recording.d.ts +4 -0
- package/dist/synth/compose-from-recording.js +43 -6
- package/dist/synth/synthesize-from-recording.js +3 -0
- package/dist-cjs/synth/compose-from-recording.d.ts +4 -0
- package/dist-cjs/synth/compose-from-recording.js +43 -6
- package/dist-cjs/synth/synthesize-from-recording.js +3 -0
- package/package.json +1 -1
- package/src/synth/compose-from-recording.ts +50 -6
- package/src/synth/synthesize-from-recording.ts +3 -0
|
@@ -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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// call
|
|
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.
|
|
84
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// call
|
|
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.
|
|
87
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// call
|
|
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.
|
|
149
|
-
//
|
|
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
|
|