@crediolabs/policy-synth 0.5.2 → 0.5.4
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/review-card/builder.js +25 -4
- 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/review-card/builder.js +25 -4
- 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/review-card/builder.ts +24 -4
- package/src/synth/compose-from-recording.ts +50 -6
- package/src/synth/synthesize-from-recording.ts +3 -0
|
@@ -107,12 +107,33 @@ function leftArgLabel(leaf) {
|
|
|
107
107
|
}
|
|
108
108
|
/** Render ONE constraint sentence for ONE interpreter predicate node. The
|
|
109
109
|
* shape of the output is pinned by Task 7b so the test suite can assert
|
|
110
|
-
* byte-for-byte equality. Returns `null` when
|
|
111
|
-
*
|
|
110
|
+
* byte-for-byte equality. Returns `null` only when a node's shape cannot be
|
|
111
|
+
* rendered at all. */
|
|
112
112
|
function renderConstraint(node) {
|
|
113
113
|
switch (node.op) {
|
|
114
|
-
case 'and':
|
|
115
|
-
|
|
114
|
+
case 'and': {
|
|
115
|
+
// Reached ONLY when an `and` sits BENEATH an `or`: `walkPredicate`
|
|
116
|
+
// descends into a top-level `and` and never calls this on one.
|
|
117
|
+
//
|
|
118
|
+
// This used to return null, on the reasoning that `and` is structural
|
|
119
|
+
// rather than a constraint leaf. That is true for the walk path and
|
|
120
|
+
// false for this one, and the combination silently dropped real
|
|
121
|
+
// policies: `or(and, and)` is the natural shape of a policy with
|
|
122
|
+
// alternative permitted forms - one conjunction of pins per branch -
|
|
123
|
+
// so the `or` case below always saw null children and withheld the
|
|
124
|
+
// entire disjunction. `describePredicate` then returned an EMPTY list
|
|
125
|
+
// for a restrictive policy, which any caller renders as "no
|
|
126
|
+
// constraints".
|
|
127
|
+
//
|
|
128
|
+
// That inverts the very guard it was protecting: the `or` case
|
|
129
|
+
// withholds to avoid reading STRICTER than reality, but withholding
|
|
130
|
+
// the only line reads as UNRESTRICTED, which is far worse. Compose
|
|
131
|
+
// instead, and keep the withhold for genuinely unrenderable children.
|
|
132
|
+
const parts = node.children.map(renderConstraint);
|
|
133
|
+
if (parts.some((p) => p === null))
|
|
134
|
+
return null;
|
|
135
|
+
return parts.join(' and ');
|
|
136
|
+
}
|
|
116
137
|
case 'or': {
|
|
117
138
|
// One line for the whole disjunction. If any branch is a shape the
|
|
118
139
|
// card cannot render, the entire line is withheld rather than shown
|
|
@@ -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
|
|
@@ -111,12 +111,33 @@ function leftArgLabel(leaf) {
|
|
|
111
111
|
}
|
|
112
112
|
/** Render ONE constraint sentence for ONE interpreter predicate node. The
|
|
113
113
|
* shape of the output is pinned by Task 7b so the test suite can assert
|
|
114
|
-
* byte-for-byte equality. Returns `null` when
|
|
115
|
-
*
|
|
114
|
+
* byte-for-byte equality. Returns `null` only when a node's shape cannot be
|
|
115
|
+
* rendered at all. */
|
|
116
116
|
function renderConstraint(node) {
|
|
117
117
|
switch (node.op) {
|
|
118
|
-
case 'and':
|
|
119
|
-
|
|
118
|
+
case 'and': {
|
|
119
|
+
// Reached ONLY when an `and` sits BENEATH an `or`: `walkPredicate`
|
|
120
|
+
// descends into a top-level `and` and never calls this on one.
|
|
121
|
+
//
|
|
122
|
+
// This used to return null, on the reasoning that `and` is structural
|
|
123
|
+
// rather than a constraint leaf. That is true for the walk path and
|
|
124
|
+
// false for this one, and the combination silently dropped real
|
|
125
|
+
// policies: `or(and, and)` is the natural shape of a policy with
|
|
126
|
+
// alternative permitted forms - one conjunction of pins per branch -
|
|
127
|
+
// so the `or` case below always saw null children and withheld the
|
|
128
|
+
// entire disjunction. `describePredicate` then returned an EMPTY list
|
|
129
|
+
// for a restrictive policy, which any caller renders as "no
|
|
130
|
+
// constraints".
|
|
131
|
+
//
|
|
132
|
+
// That inverts the very guard it was protecting: the `or` case
|
|
133
|
+
// withholds to avoid reading STRICTER than reality, but withholding
|
|
134
|
+
// the only line reads as UNRESTRICTED, which is far worse. Compose
|
|
135
|
+
// instead, and keep the withhold for genuinely unrenderable children.
|
|
136
|
+
const parts = node.children.map(renderConstraint);
|
|
137
|
+
if (parts.some((p) => p === null))
|
|
138
|
+
return null;
|
|
139
|
+
return parts.join(' and ');
|
|
140
|
+
}
|
|
120
141
|
case 'or': {
|
|
121
142
|
// One line for the whole disjunction. If any branch is a shape the
|
|
122
143
|
// card cannot render, the entire line is withheld rather than shown
|
|
@@ -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.4",
|
|
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",
|
|
@@ -134,12 +134,32 @@ function leftArgLabel(leaf: PredicateLeaf): string {
|
|
|
134
134
|
|
|
135
135
|
/** Render ONE constraint sentence for ONE interpreter predicate node. The
|
|
136
136
|
* shape of the output is pinned by Task 7b so the test suite can assert
|
|
137
|
-
* byte-for-byte equality. Returns `null` when
|
|
138
|
-
*
|
|
137
|
+
* byte-for-byte equality. Returns `null` only when a node's shape cannot be
|
|
138
|
+
* rendered at all. */
|
|
139
139
|
function renderConstraint(node: PredicateNode): string | null {
|
|
140
140
|
switch (node.op) {
|
|
141
|
-
case 'and':
|
|
142
|
-
|
|
141
|
+
case 'and': {
|
|
142
|
+
// Reached ONLY when an `and` sits BENEATH an `or`: `walkPredicate`
|
|
143
|
+
// descends into a top-level `and` and never calls this on one.
|
|
144
|
+
//
|
|
145
|
+
// This used to return null, on the reasoning that `and` is structural
|
|
146
|
+
// rather than a constraint leaf. That is true for the walk path and
|
|
147
|
+
// false for this one, and the combination silently dropped real
|
|
148
|
+
// policies: `or(and, and)` is the natural shape of a policy with
|
|
149
|
+
// alternative permitted forms - one conjunction of pins per branch -
|
|
150
|
+
// so the `or` case below always saw null children and withheld the
|
|
151
|
+
// entire disjunction. `describePredicate` then returned an EMPTY list
|
|
152
|
+
// for a restrictive policy, which any caller renders as "no
|
|
153
|
+
// constraints".
|
|
154
|
+
//
|
|
155
|
+
// That inverts the very guard it was protecting: the `or` case
|
|
156
|
+
// withholds to avoid reading STRICTER than reality, but withholding
|
|
157
|
+
// the only line reads as UNRESTRICTED, which is far worse. Compose
|
|
158
|
+
// instead, and keep the withhold for genuinely unrenderable children.
|
|
159
|
+
const parts = node.children.map(renderConstraint)
|
|
160
|
+
if (parts.some((p) => p === null)) return null
|
|
161
|
+
return parts.join(' and ')
|
|
162
|
+
}
|
|
143
163
|
case 'or': {
|
|
144
164
|
// One line for the whole disjunction. If any branch is a shape the
|
|
145
165
|
// card cannot render, the entire line is withheld rather than shown
|
|
@@ -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
|
|