@crediolabs/policy-synth 0.1.0
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/adapters/oz/adapter.d.ts +20 -0
- package/dist/adapters/oz/adapter.js +282 -0
- package/dist/adapters/oz/index.d.ts +1 -0
- package/dist/adapters/oz/index.js +2 -0
- package/dist/errors.d.ts +37 -0
- package/dist/errors.js +2 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/ir/index.d.ts +1 -0
- package/dist/ir/index.js +2 -0
- package/dist/ir/types.d.ts +97 -0
- package/dist/ir/types.js +11 -0
- package/dist/mandate/index.d.ts +2 -0
- package/dist/mandate/index.js +2 -0
- package/dist/mandate/to-ir.d.ts +3 -0
- package/dist/mandate/to-ir.js +60 -0
- package/dist/mandate/types.d.ts +20 -0
- package/dist/mandate/types.js +8 -0
- package/dist/record/decode.d.ts +76 -0
- package/dist/record/decode.js +372 -0
- package/dist/record/freshness.d.ts +17 -0
- package/dist/record/freshness.js +50 -0
- package/dist/record/index.d.ts +21 -0
- package/dist/record/index.js +163 -0
- package/dist/record/movements.d.ts +20 -0
- package/dist/record/movements.js +187 -0
- package/dist/record/rpc.d.ts +22 -0
- package/dist/record/rpc.js +70 -0
- package/dist/record/validate.d.ts +22 -0
- package/dist/record/validate.js +60 -0
- package/dist/registry/identify.d.ts +11 -0
- package/dist/registry/identify.js +84 -0
- package/dist/registry/index.d.ts +3 -0
- package/dist/registry/index.js +4 -0
- package/dist/registry/known-addresses.d.ts +16 -0
- package/dist/registry/known-addresses.js +49 -0
- package/dist/registry/protocols.d.ts +38 -0
- package/dist/registry/protocols.js +149 -0
- package/dist/seams/index.d.ts +1 -0
- package/dist/seams/index.js +2 -0
- package/dist/seams/types.d.ts +66 -0
- package/dist/seams/types.js +11 -0
- package/dist/synth/compose-from-recording.d.ts +36 -0
- package/dist/synth/compose-from-recording.js +162 -0
- package/dist/synth/index.d.ts +5 -0
- package/dist/synth/index.js +6 -0
- package/dist/synth/lower.d.ts +23 -0
- package/dist/synth/lower.js +116 -0
- package/dist/synth/scope.d.ts +26 -0
- package/dist/synth/scope.js +77 -0
- package/dist/synth/synthesize-from-mandate.d.ts +5 -0
- package/dist/synth/synthesize-from-mandate.js +34 -0
- package/dist/synth/synthesize-from-recording.d.ts +17 -0
- package/dist/synth/synthesize-from-recording.js +178 -0
- package/dist/types.d.ts +249 -0
- package/dist/types.js +35 -0
- package/package.json +37 -0
- package/src/adapters/oz/adapter.ts +363 -0
- package/src/adapters/oz/index.ts +9 -0
- package/src/errors.ts +79 -0
- package/src/index.ts +9 -0
- package/src/ir/index.ts +13 -0
- package/src/ir/types.ts +94 -0
- package/src/mandate/index.ts +4 -0
- package/src/mandate/to-ir.ts +71 -0
- package/src/mandate/types.ts +21 -0
- package/src/record/decode.ts +500 -0
- package/src/record/freshness.ts +63 -0
- package/src/record/index.ts +224 -0
- package/src/record/movements.ts +188 -0
- package/src/record/rpc.ts +88 -0
- package/src/record/validate.ts +75 -0
- package/src/registry/identify.ts +99 -0
- package/src/registry/index.ts +24 -0
- package/src/registry/known-addresses.ts +71 -0
- package/src/registry/protocols.ts +176 -0
- package/src/seams/index.ts +11 -0
- package/src/seams/types.ts +81 -0
- package/src/synth/compose-from-recording.ts +226 -0
- package/src/synth/index.ts +19 -0
- package/src/synth/lower.ts +144 -0
- package/src/synth/scope.ts +108 -0
- package/src/synth/synthesize-from-mandate.ts +46 -0
- package/src/synth/synthesize-from-recording.ts +226 -0
- package/src/types.ts +218 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// src/registry/index.ts - re-export the protocol adjacency registry.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
type IdentifiedProtocol,
|
|
5
|
+
identifyProtocol,
|
|
6
|
+
} from './identify.ts'
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
addressToProtocol,
|
|
10
|
+
KNOWN_ADDRESSES,
|
|
11
|
+
type KnownAddressesByNetwork,
|
|
12
|
+
type KnownAddressesByProtocol,
|
|
13
|
+
} from './known-addresses.ts'
|
|
14
|
+
export {
|
|
15
|
+
type AbiArg,
|
|
16
|
+
type AbiEntry,
|
|
17
|
+
BLEND_ABI,
|
|
18
|
+
getAbi,
|
|
19
|
+
PROTOCOL_ABIS,
|
|
20
|
+
type ProtocolAbi,
|
|
21
|
+
type ProtocolId,
|
|
22
|
+
SEP41_ABI,
|
|
23
|
+
SOROSWAP_ABI,
|
|
24
|
+
} from './protocols.ts'
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/registry/known-addresses.ts - pinned real protocol contract addresses.
|
|
2
|
+
//
|
|
3
|
+
// Addresses here are fetched from authoritative public sources:
|
|
4
|
+
// - Blend mainnet: docs.blend.capital/mainnet-deployments
|
|
5
|
+
// https://docs.blend.capital/mainnet-deployments
|
|
6
|
+
// - SoroSwap mainnet: github.com/soroswap (organisation README)
|
|
7
|
+
// https://github.com/soroswap
|
|
8
|
+
//
|
|
9
|
+
// Testnet addresses are intentionally NOT pinned: the public docs do not
|
|
10
|
+
// publish them, and per the task brief, unconfirmed addresses are marked
|
|
11
|
+
// [VERIFY] and excluded from the active set rather than invented.
|
|
12
|
+
//
|
|
13
|
+
// SEP-41 is recognised by interface (any token), so no address list is needed
|
|
14
|
+
// for it - see identify.ts.
|
|
15
|
+
|
|
16
|
+
import type { ProtocolId } from './protocols.ts'
|
|
17
|
+
|
|
18
|
+
/** Pinned contract addresses per (network, protocol). Empty list = no
|
|
19
|
+
* addresses pinned for that cell. */
|
|
20
|
+
export interface KnownAddressesByProtocol {
|
|
21
|
+
blend: string[]
|
|
22
|
+
soroswap: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface KnownAddressesByNetwork {
|
|
26
|
+
mainnet: KnownAddressesByProtocol
|
|
27
|
+
testnet: KnownAddressesByProtocol
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Source: https://docs.blend.capital/mainnet-deployments (Blend Pool Factory
|
|
31
|
+
// mainnet deployment, fetched 2026-07-23).
|
|
32
|
+
const BLEND_MAINNET_POOL_FACTORY = 'CDSYOAVXFY7SM5S64IZPPPYB4GVGGLMQVFREPSQQEZVIWXX5R23G4QSU'
|
|
33
|
+
|
|
34
|
+
// Source: https://github.com/soroswap (organisation README, fetched 2026-07-23)
|
|
35
|
+
// SoroswapRouter mainnet deployment.
|
|
36
|
+
const SOROSWAP_MAINNET_ROUTER = 'CAG5LRYQ5JVEUI5TEID72EYOVX44TTUJT5BQR2J6J77FH65PCCFAJDDH'
|
|
37
|
+
|
|
38
|
+
// Source: https://github.com/soroswap (organisation README, fetched 2026-07-23)
|
|
39
|
+
// SoroswapFactory mainnet deployment.
|
|
40
|
+
const SOROSWAP_MAINNET_FACTORY = 'CA4HEQTL2WPEUYKYKCDOHCDNIV4QHNJ7EL4J4NQ6VADP7SYHVRYZ7AW2'
|
|
41
|
+
|
|
42
|
+
export const KNOWN_ADDRESSES: KnownAddressesByNetwork = {
|
|
43
|
+
mainnet: {
|
|
44
|
+
blend: [BLEND_MAINNET_POOL_FACTORY],
|
|
45
|
+
soroswap: [SOROSWAP_MAINNET_ROUTER, SOROSWAP_MAINNET_FACTORY],
|
|
46
|
+
},
|
|
47
|
+
testnet: {
|
|
48
|
+
// [VERIFY] Blend testnet pool factory address: not published in the
|
|
49
|
+
// public docs as of 2026-07-23. Excluded from the active set until an
|
|
50
|
+
// authoritative source is found.
|
|
51
|
+
blend: [],
|
|
52
|
+
// [VERIFY] SoroSwap testnet router / factory addresses: not published in
|
|
53
|
+
// the public docs as of 2026-07-23. Excluded from the active set.
|
|
54
|
+
soroswap: [],
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Map a (contract, network) pair to its pinned protocol id. Returns null
|
|
59
|
+
* when the address is not pinned for that network - callers MUST preserve
|
|
60
|
+
* the fail-closed posture for unpinned contracts. */
|
|
61
|
+
export function addressToProtocol(
|
|
62
|
+
contract: string,
|
|
63
|
+
network: 'mainnet' | 'testnet'
|
|
64
|
+
): ProtocolId | null {
|
|
65
|
+
for (const protocol of ['blend', 'soroswap'] as const) {
|
|
66
|
+
if (KNOWN_ADDRESSES[network][protocol].includes(contract)) {
|
|
67
|
+
return protocol
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// src/registry/protocols.ts - protocol ABI definitions (no addresses).
|
|
2
|
+
//
|
|
3
|
+
// A protocol is identified by EITHER:
|
|
4
|
+
// - by interface (function-name signature + arg shape): a contract calling
|
|
5
|
+
// one of these well-known fns with the expected arg shape IS that
|
|
6
|
+
// protocol, regardless of address. SEP-41 is the canonical case - ANY
|
|
7
|
+
// token contract calling `transfer` with the right arg shape is a
|
|
8
|
+
// SEP-41 token. Blend pool `submit`/`claim` are similarly recognised by
|
|
9
|
+
// interface (the pool factory address doesn't cover per-pool instances).
|
|
10
|
+
// - by address: the contract is in the pinned `known-addresses` set (see
|
|
11
|
+
// known-addresses.ts). SoroSwap uses address recognition because the
|
|
12
|
+
// router is a single deployed contract; Blend uses it to recognise factory
|
|
13
|
+
// calls in addition to its interface recognition.
|
|
14
|
+
//
|
|
15
|
+
// Args entries pin a ScVal subset type (matching the normalised `ScVal`
|
|
16
|
+
// vocabulary in src/types.ts) plus a human meaning string. The registry
|
|
17
|
+
// fails closed: a method call whose decoded args do NOT match the ABI
|
|
18
|
+
// signature is treated as unknown.
|
|
19
|
+
|
|
20
|
+
import type { ScVal } from '../types.ts'
|
|
21
|
+
|
|
22
|
+
export type ProtocolId = 'sep41' | 'blend' | 'soroswap'
|
|
23
|
+
|
|
24
|
+
/** ScVal subset type vocabulary the ABI uses. Mirrors `ScVal['type']` minus
|
|
25
|
+
* `other` (which by definition cannot be matched against an ABI arg). */
|
|
26
|
+
export type AbiArgType = Exclude<ScVal['type'], 'other'>
|
|
27
|
+
|
|
28
|
+
export interface AbiArg {
|
|
29
|
+
name: string
|
|
30
|
+
/** ScVal subset type the decoded arg must conform to. */
|
|
31
|
+
type: AbiArgType
|
|
32
|
+
/** Human-readable meaning for the downstream reviewer. */
|
|
33
|
+
meaning: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AbiEntry {
|
|
37
|
+
args: AbiArg[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ProtocolAbi = Record<string, AbiEntry>
|
|
41
|
+
|
|
42
|
+
/** SEP-41 token interface - pinned from the SEP-41 spec (any token). */
|
|
43
|
+
export const SEP41_ABI: ProtocolAbi = {
|
|
44
|
+
transfer: {
|
|
45
|
+
args: [
|
|
46
|
+
{
|
|
47
|
+
name: 'from',
|
|
48
|
+
type: 'address',
|
|
49
|
+
meaning: 'source address (the `from` of the token transfer)',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'to',
|
|
53
|
+
type: 'address',
|
|
54
|
+
meaning: 'destination address (the `to` of the token transfer)',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'amount',
|
|
58
|
+
type: 'i128',
|
|
59
|
+
meaning: 'amount of tokens to move (i128, signed-decimal string)',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
mint: {
|
|
64
|
+
args: [
|
|
65
|
+
{ name: 'to', type: 'address', meaning: 'recipient of the newly minted tokens' },
|
|
66
|
+
{ name: 'amount', type: 'i128', meaning: 'amount to mint (i128, signed-decimal string)' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
burn: {
|
|
70
|
+
args: [
|
|
71
|
+
{ name: 'from', type: 'address', meaning: 'address whose balance is decremented' },
|
|
72
|
+
{ name: 'amount', type: 'i128', meaning: 'amount to burn (i128, signed-decimal string)' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
approve: {
|
|
76
|
+
args: [
|
|
77
|
+
{ name: 'from', type: 'address', meaning: 'token holder granting the allowance' },
|
|
78
|
+
{
|
|
79
|
+
name: 'spender',
|
|
80
|
+
type: 'address',
|
|
81
|
+
meaning: 'address authorised to move tokens on behalf of `from`',
|
|
82
|
+
},
|
|
83
|
+
{ name: 'amount', type: 'i128', meaning: 'allowance amount (i128, signed-decimal string)' },
|
|
84
|
+
{
|
|
85
|
+
name: 'expiration_ledger',
|
|
86
|
+
type: 'u32',
|
|
87
|
+
meaning: 'ledger sequence after which the allowance is invalid',
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Blend pool interface (v2). Pinning the two fns the recorder surfaces.
|
|
94
|
+
* Pool-level recognition is by INTERFACE (real pool calls hit per-pool
|
|
95
|
+
* instances, not the factory). Arg shapes verified against the Blend v2
|
|
96
|
+
* pool contract source (blend-capital/blend-contracts-v2 `pool/src/contract.rs`,
|
|
97
|
+
* fetched 2026-07-23):
|
|
98
|
+
* submit(env, from: Address, spender: Address, to: Address, requests: Vec<Request>)
|
|
99
|
+
* -> Positions
|
|
100
|
+
* claim(env, from: Address, reserve_token_ids: Vec<u32>, to: Address) -> i128
|
|
101
|
+
* Other pool fns (add_reserves, withdraw, etc.) are intentionally omitted -
|
|
102
|
+
* they are too version-sensitive to bind by interface. */
|
|
103
|
+
export const BLEND_ABI: ProtocolAbi = {
|
|
104
|
+
submit: {
|
|
105
|
+
args: [
|
|
106
|
+
{ name: 'from', type: 'address', meaning: 'address supplying collateral or repaying debt' },
|
|
107
|
+
{ name: 'spender', type: 'address', meaning: 'address authorising the supply / repay' },
|
|
108
|
+
{ name: 'to', type: 'address', meaning: 'address receiving the resulting position shares' },
|
|
109
|
+
{ name: 'requests', type: 'vec', meaning: 'vec<Request> describing the action per reserve' },
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
claim: {
|
|
113
|
+
args: [
|
|
114
|
+
{ name: 'from', type: 'address', meaning: 'address whose position is being claimed against' },
|
|
115
|
+
{
|
|
116
|
+
name: 'reserve_token_ids',
|
|
117
|
+
type: 'vec',
|
|
118
|
+
meaning: 'vec<u32> reserve token ids to claim emissions for',
|
|
119
|
+
},
|
|
120
|
+
{ name: 'to', type: 'address', meaning: 'address receiving the claimed tokens' },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** SoroSwap router interface. Standard Uniswap-V2-style swap fn signatures
|
|
126
|
+
* pinned from the SoroSwap router source. Recognised by address only (the
|
|
127
|
+
* router is a single pinned contract); FIX 4 requires the method to also be
|
|
128
|
+
* in this ABI for address recognition to succeed. The `path` arg is a
|
|
129
|
+
* `vec<address>` - we record it as the outer `vec` subset and leave the
|
|
130
|
+
* per-element shape to the downstream reviewer. */
|
|
131
|
+
export const SOROSWAP_ABI: ProtocolAbi = {
|
|
132
|
+
swap_exact_tokens_for_tokens: {
|
|
133
|
+
args: [
|
|
134
|
+
{ name: 'amount_in', type: 'i128', meaning: 'exact input amount (i128)' },
|
|
135
|
+
{ name: 'amount_out_min', type: 'i128', meaning: 'minimum acceptable output (i128)' },
|
|
136
|
+
{ name: 'path', type: 'vec', meaning: 'vec<address> hop path through pools' },
|
|
137
|
+
{ name: 'to', type: 'address', meaning: 'recipient of the output tokens' },
|
|
138
|
+
{
|
|
139
|
+
name: 'deadline',
|
|
140
|
+
type: 'u64',
|
|
141
|
+
meaning: 'unix-seconds deadline after which the swap is refused',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
swap_tokens_for_exact_tokens: {
|
|
146
|
+
args: [
|
|
147
|
+
{ name: 'amount_out', type: 'i128', meaning: 'exact output amount desired (i128)' },
|
|
148
|
+
{ name: 'amount_in_max', type: 'i128', meaning: 'maximum input willing to spend (i128)' },
|
|
149
|
+
{ name: 'path', type: 'vec', meaning: 'vec<address> hop path through pools' },
|
|
150
|
+
{ name: 'to', type: 'address', meaning: 'recipient of the output tokens' },
|
|
151
|
+
{
|
|
152
|
+
name: 'deadline',
|
|
153
|
+
type: 'u64',
|
|
154
|
+
meaning: 'unix-seconds deadline after which the swap is refused',
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
swap_exact_in_for_tokens: {
|
|
159
|
+
args: [
|
|
160
|
+
{ name: 'amount_in', type: 'i128', meaning: 'exact input amount (i128)' },
|
|
161
|
+
{ name: 'amount_out_min', type: 'i128', meaning: 'minimum acceptable output (i128)' },
|
|
162
|
+
{ name: 'path', type: 'vec', meaning: 'vec<address> hop path through pools' },
|
|
163
|
+
{ name: 'to', type: 'address', meaning: 'recipient of the output tokens' },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const PROTOCOL_ABIS: Record<ProtocolId, ProtocolAbi> = {
|
|
169
|
+
sep41: SEP41_ABI,
|
|
170
|
+
blend: BLEND_ABI,
|
|
171
|
+
soroswap: SOROSWAP_ABI,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function getAbi(protocol: ProtocolId): ProtocolAbi {
|
|
175
|
+
return PROTOCOL_ABIS[protocol]
|
|
176
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// src/seams/types.ts - the three custody seams (contracts, not implementations).
|
|
2
|
+
//
|
|
3
|
+
// The synthesizer is decoupled from its edges by three seams:
|
|
4
|
+
// - PolicySource (INPUT): produces a normalized RecordedTransaction. The
|
|
5
|
+
// recorder is the reference source; a MandateSpec
|
|
6
|
+
// lowering is a second, deterministic source.
|
|
7
|
+
// - ChainDecoder (DECODE): chain-specific call data -> normalized facts.
|
|
8
|
+
// - CustodyAdapter (OUTPUT): compile a PolicyIR to a backend-native policy +
|
|
9
|
+
// verify/export it (OZ is the first adapter).
|
|
10
|
+
// These are INTERFACES only. Implementations live in their own modules.
|
|
11
|
+
|
|
12
|
+
import type { ToolResponse } from '../errors.ts'
|
|
13
|
+
import type { PolicyIR } from '../ir/types.ts'
|
|
14
|
+
import type { Network, ProposedPolicy, RecordedTransaction, ScVal } from '../types.ts'
|
|
15
|
+
|
|
16
|
+
/** INPUT: something that produces a normalized RecordedTransaction. */
|
|
17
|
+
export interface PolicySource {
|
|
18
|
+
readonly name: string
|
|
19
|
+
capture(input: {
|
|
20
|
+
hash?: string
|
|
21
|
+
xdr?: string
|
|
22
|
+
network: Network
|
|
23
|
+
}): Promise<ToolResponse<RecordedTransaction>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** DECODE: chain-specific call data -> the normalized facts the synth reasons
|
|
27
|
+
* over. */
|
|
28
|
+
export interface ChainDecoder {
|
|
29
|
+
readonly chain: 'stellar' | 'evm'
|
|
30
|
+
decodeInvocation(tx: RecordedTransaction): { contract: string; method: string; args: ScVal[] }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** OZ = enforce; `log_only` is reserved for shadow mode (out of scope). */
|
|
34
|
+
export type CustodyMode = 'enforce' | 'log_only'
|
|
35
|
+
|
|
36
|
+
/** What a backend can express. A construct needing a false flag is flagged
|
|
37
|
+
* `uncovered` by the adapter, never silently dropped. */
|
|
38
|
+
export interface CustodyCapabilities {
|
|
39
|
+
supportsOraclePrice: boolean
|
|
40
|
+
supportsSpendWindow: boolean
|
|
41
|
+
supportsInvocationCount: boolean
|
|
42
|
+
supportsTimeExpiry: boolean
|
|
43
|
+
supportsThreshold: boolean
|
|
44
|
+
supportsGeneralPredicate: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The result of compiling a PolicyIR for one backend. */
|
|
48
|
+
export interface CompileResult {
|
|
49
|
+
/** false => some IR construct this backend cannot express (see `uncovered`). */
|
|
50
|
+
covered: boolean
|
|
51
|
+
/** Human-readable list of unsupported constructs (the Path-B markers). */
|
|
52
|
+
uncovered: string[]
|
|
53
|
+
/** The backend-native installable policy, assembled when a rule lowered. */
|
|
54
|
+
proposed?: ProposedPolicy
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Result of a simulate() dry-run. `ts-model` is the off-chain TS evaluator.
|
|
58
|
+
* Real permit/deny semantics wiring is a later phase; week-1 returns a
|
|
59
|
+
* clearly-marked stub (empty `evaluations`, `permitted: null`). */
|
|
60
|
+
export interface SimulationResult {
|
|
61
|
+
backend: 'ts-model'
|
|
62
|
+
/** Whether the permit tx would be allowed; null until real semantics land. */
|
|
63
|
+
permitted: boolean | null
|
|
64
|
+
/** Per-construct evaluation trace; empty placeholder in this slice. */
|
|
65
|
+
evaluations: unknown[]
|
|
66
|
+
/** Diagnostics; marks the stub explicitly. */
|
|
67
|
+
notes: string[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** OUTPUT: compile a PolicyIR to a backend-specific installable policy. */
|
|
71
|
+
export interface CustodyAdapter {
|
|
72
|
+
readonly name: string
|
|
73
|
+
readonly mode: CustodyMode
|
|
74
|
+
capabilities(): CustodyCapabilities
|
|
75
|
+
compile(ir: PolicyIR): CompileResult
|
|
76
|
+
/** Phase-03 wiring; a clearly-marked stub in week-1. */
|
|
77
|
+
simulate(ir: PolicyIR, permitTx: RecordedTransaction): SimulationResult
|
|
78
|
+
/** Canonical JSON of the IR (portability / audit). */
|
|
79
|
+
export(ir: PolicyIR): string
|
|
80
|
+
// install(...) lands with a later phase; not part of this slice.
|
|
81
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// src/synth/compose-from-recording.ts - facts + scope -> PolicyIR (Path A).
|
|
2
|
+
//
|
|
3
|
+
// Composes the canonical IR rule the OZ Path-A backend can compile. Fail-closed
|
|
4
|
+
// composition rules:
|
|
5
|
+
//
|
|
6
|
+
// - identify the protocol of the top-level call (registry.identifyProtocol).
|
|
7
|
+
// When it is unknown (null), emit NO OZ-primitive-producing IR node: the
|
|
8
|
+
// scope is kept (CallContract + method) and every inferred bound is surfaced
|
|
9
|
+
// as a descriptive Path-B warning. An unrecognised call never compiles to a
|
|
10
|
+
// permissive OZ primitive.
|
|
11
|
+
//
|
|
12
|
+
// - carry the recorded top-level function into `rule.scope.method` so the OZ
|
|
13
|
+
// adapter flags per-method scoping Path-B (CallContract permits every method
|
|
14
|
+
// on the contract; a per-method restriction needs the interpreter predicate).
|
|
15
|
+
//
|
|
16
|
+
// - a `spending_limit` (window_spent(token, w) <= limit) is emitted ONLY when
|
|
17
|
+
// the caller supplies BOTH the limit (userResponses.limitAmount) and the
|
|
18
|
+
// window (userResponses.windowSeconds). A single recorded spend does NOT
|
|
19
|
+
// authorise that amount every window, so the observed amount is NEVER used as
|
|
20
|
+
// an auto-ceiling: a missing limit surfaces AMOUNT_BOUND_MISSING (the observed
|
|
21
|
+
// amount is offered as a suggestion), a missing window surfaces
|
|
22
|
+
// DURATION_UNSPECIFIED. EVERY spent token is handled - none is silently
|
|
23
|
+
// dropped; a single caller limit binds only an unambiguous single-token spend.
|
|
24
|
+
//
|
|
25
|
+
// - incoming-only flows (e.g. Blend yield claim) emit an `invocation_count`
|
|
26
|
+
// bound ONLY when the caller supplies the count (userResponses.invocationLimit)
|
|
27
|
+
// and a window; otherwise FREQUENCY_BOUND_MISSING is surfaced with no
|
|
28
|
+
// fabricated count.
|
|
29
|
+
//
|
|
30
|
+
// - the IR carries ONLY constraints justified by the recording (observed
|
|
31
|
+
// recipient allowlist) + explicit user input. Nothing invented: no oracle
|
|
32
|
+
// price fabricated from a slippage bound, no synthetic exact-path compare.
|
|
33
|
+
// Those needs are surfaced as descriptive Path-B warnings instead.
|
|
34
|
+
//
|
|
35
|
+
// The default behavior is `deny_all` (OZ context rules are deny-by-default).
|
|
36
|
+
|
|
37
|
+
import type { IRCondition, IRPolicyRule, PolicyIR } from '../ir/types.ts'
|
|
38
|
+
import { type IdentifiedProtocol, identifyProtocol } from '../registry/identify.ts'
|
|
39
|
+
import type { AmbiguityPrompt, ContractInvocation, Network } from '../types.ts'
|
|
40
|
+
import type { IntentFacts } from './lower.ts'
|
|
41
|
+
|
|
42
|
+
/** Caller-supplied answers to the ambiguity prompts. Every numeric bound the
|
|
43
|
+
* synth might apply must come from here - the recording supplies observed
|
|
44
|
+
* amounts (offered only as suggestions), never authorised ceilings. */
|
|
45
|
+
export interface ComposeUserResponses {
|
|
46
|
+
/** Rolling window (seconds) for a spending_limit / invocation_count. */
|
|
47
|
+
windowSeconds?: number
|
|
48
|
+
/** OZ context-rule expiry (ledger sequence). */
|
|
49
|
+
validUntilLedger?: number
|
|
50
|
+
/** Per-window spend ceiling (i128 decimal string). Required to emit a
|
|
51
|
+
* spending_limit; absent -> AMOUNT_BOUND_MISSING. */
|
|
52
|
+
limitAmount?: string
|
|
53
|
+
/** Max invocations per window for an incoming-only flow. Required to emit an
|
|
54
|
+
* invocation_count bound; absent -> FREQUENCY_BOUND_MISSING. */
|
|
55
|
+
invocationLimit?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Composition options. */
|
|
59
|
+
export interface ComposeOptions {
|
|
60
|
+
network: Network
|
|
61
|
+
userResponses?: ComposeUserResponses
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Result of composition: the PolicyIR, any ambiguities surfaced during
|
|
65
|
+
* inference, and descriptive Path-B warnings for needs that are NOT expressed
|
|
66
|
+
* as an IR node (so no fabricated constraint is emitted). The orchestrator
|
|
67
|
+
* carries ambiguities into `ProposedPolicy.ambiguities` and merges warnings
|
|
68
|
+
* into `ProposedPolicy.warnings`. */
|
|
69
|
+
export interface ComposeResult {
|
|
70
|
+
ir: PolicyIR
|
|
71
|
+
ambiguities: AmbiguityPrompt[]
|
|
72
|
+
warnings: string[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Compose a PolicyIR from the lowered facts + the resolved scope.
|
|
76
|
+
* Pure (no randomness, no clock); same inputs -> byte-identical result. */
|
|
77
|
+
export function composeFromRecording(
|
|
78
|
+
facts: IntentFacts,
|
|
79
|
+
scopeContract: string,
|
|
80
|
+
topLevel: ContractInvocation | null,
|
|
81
|
+
opts: ComposeOptions
|
|
82
|
+
): ComposeResult {
|
|
83
|
+
const ambiguities: AmbiguityPrompt[] = []
|
|
84
|
+
const warnings: string[] = []
|
|
85
|
+
const constraints: IRCondition[] = []
|
|
86
|
+
|
|
87
|
+
const protocol = topLevel
|
|
88
|
+
? identifyProtocol(topLevel.contract, topLevel.fn, topLevel.args, opts.network)
|
|
89
|
+
: null
|
|
90
|
+
const known = protocol !== null
|
|
91
|
+
|
|
92
|
+
const windowSeconds = opts.userResponses?.windowSeconds
|
|
93
|
+
const limitAmount = opts.userResponses?.limitAmount
|
|
94
|
+
const spendTokens = Object.keys(facts.spendByToken)
|
|
95
|
+
|
|
96
|
+
// Outgoing spend -> one spending_limit per spent token. A single caller limit
|
|
97
|
+
// binds only an unambiguous single-token spend; a multi-token flow needs a
|
|
98
|
+
// per-token limit, so each unmatched token surfaces AMOUNT_BOUND_MISSING.
|
|
99
|
+
if (spendTokens.length > 0 && topLevel) {
|
|
100
|
+
let durationFlagged = false
|
|
101
|
+
for (const token of spendTokens) {
|
|
102
|
+
const observed = facts.spendByToken[token]
|
|
103
|
+
if (observed === undefined) continue
|
|
104
|
+
|
|
105
|
+
if (!known) {
|
|
106
|
+
warnings.push(
|
|
107
|
+
`spend of ${observed} (token ${token}) not bounded: unrecognised protocol, spend cap needs the interpreter predicate (Path B)`
|
|
108
|
+
)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const limit = spendTokens.length === 1 ? limitAmount : undefined
|
|
113
|
+
if (windowSeconds === undefined && !durationFlagged) {
|
|
114
|
+
ambiguities.push({
|
|
115
|
+
code: 'DURATION_UNSPECIFIED',
|
|
116
|
+
question: `Recording shows a ${observed}-${token} spend. What rolling window (seconds) should the spending_limit use?`,
|
|
117
|
+
})
|
|
118
|
+
durationFlagged = true
|
|
119
|
+
}
|
|
120
|
+
if (limit === undefined) {
|
|
121
|
+
ambiguities.push({
|
|
122
|
+
code: 'AMOUNT_BOUND_MISSING',
|
|
123
|
+
question: `Recording shows a ${observed}-${token} spend, but a single spend does not authorise that amount every window. What per-window spending_limit should apply? (observed amount, suggestion only: ${observed})`,
|
|
124
|
+
})
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
if (windowSeconds !== undefined) {
|
|
128
|
+
constraints.push({
|
|
129
|
+
op: 'compare',
|
|
130
|
+
compare: {
|
|
131
|
+
selector: { kind: 'window_spent', token, windowSeconds },
|
|
132
|
+
operator: 'lte',
|
|
133
|
+
value: limit,
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Incoming-only / frequency intent: an invocation_count bound is emitted ONLY
|
|
141
|
+
// when the caller supplies the count AND a window - never a fabricated `<= 1`.
|
|
142
|
+
if (spendTokens.length === 0 && topLevel) {
|
|
143
|
+
const invocationLimit = opts.userResponses?.invocationLimit
|
|
144
|
+
if (known && windowSeconds !== undefined && invocationLimit !== undefined) {
|
|
145
|
+
constraints.push({
|
|
146
|
+
op: 'compare',
|
|
147
|
+
compare: {
|
|
148
|
+
selector: { kind: 'invocation_count', windowSeconds },
|
|
149
|
+
operator: 'lte',
|
|
150
|
+
value: String(invocationLimit),
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
} else {
|
|
154
|
+
ambiguities.push({
|
|
155
|
+
code: 'FREQUENCY_BOUND_MISSING',
|
|
156
|
+
question: 'Incoming-only flow - what max invocations per window should the policy enforce?',
|
|
157
|
+
})
|
|
158
|
+
warnings.push(
|
|
159
|
+
'frequency bound needed for the incoming-only flow (interpreter/Path B); no invocation cap inferred'
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Observed recipient allowlist (SEP-41) is a real, recorded constraint the OZ
|
|
165
|
+
// adapter flags Path-B. Unknown protocols emit nothing here.
|
|
166
|
+
if (topLevel && protocol !== null) {
|
|
167
|
+
appendProtocolSpecificConstraints(constraints, warnings, facts, topLevel, protocol)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const scope: IRPolicyRule['scope'] = { contract: scopeContract }
|
|
171
|
+
if (topLevel?.fn) scope.method = topLevel.fn
|
|
172
|
+
|
|
173
|
+
const rule: IRPolicyRule = {
|
|
174
|
+
roles: [],
|
|
175
|
+
scope,
|
|
176
|
+
constraints,
|
|
177
|
+
}
|
|
178
|
+
if (opts.userResponses?.validUntilLedger !== undefined) {
|
|
179
|
+
rule.expiry = { validUntilLedger: opts.userResponses.validUntilLedger }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const ir: PolicyIR = {
|
|
183
|
+
chain: 'stellar',
|
|
184
|
+
defaultBehavior: 'deny_all',
|
|
185
|
+
rules: [rule],
|
|
186
|
+
}
|
|
187
|
+
return { ir, ambiguities, warnings }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Add the constraints justified by the recording that the OZ backend cannot
|
|
191
|
+
* express natively (the SEP-41 recipient the transfer moved to). The OZ adapter
|
|
192
|
+
* flags them Path-B. SoroSwap's slippage / oracle / exact-path needs are
|
|
193
|
+
* surfaced as descriptive warnings, NOT as fabricated comparison nodes. */
|
|
194
|
+
function appendProtocolSpecificConstraints(
|
|
195
|
+
constraints: IRCondition[],
|
|
196
|
+
warnings: string[],
|
|
197
|
+
facts: IntentFacts,
|
|
198
|
+
topLevel: ContractInvocation,
|
|
199
|
+
protocol: IdentifiedProtocol
|
|
200
|
+
): void {
|
|
201
|
+
// SEP-41 transfer / mint: the `to` arg (index 1) is the recipient. Emit it as
|
|
202
|
+
// a single-element allowlist; the OZ adapter flags it Path-B.
|
|
203
|
+
if (protocol.protocol === 'sep41' && (protocol.fn === 'transfer' || protocol.fn === 'mint')) {
|
|
204
|
+
const toArg = topLevel.args[1]
|
|
205
|
+
if (toArg && toArg.type === 'address') {
|
|
206
|
+
constraints.push({
|
|
207
|
+
op: 'in',
|
|
208
|
+
selector: { kind: 'arg', argIndex: 1, scalarType: 'address' },
|
|
209
|
+
values: [toArg.value],
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// SoroSwap: the swap's slippage / oracle-price bound and its exact hop path
|
|
215
|
+
// need the interpreter predicate. We do NOT fabricate a comparison node
|
|
216
|
+
// (oracle price and amountOutMin are different units; a vec path is not a
|
|
217
|
+
// scalar compare). Surface the observed path descriptively instead.
|
|
218
|
+
if (protocol.protocol === 'soroswap') {
|
|
219
|
+
const paths = facts.allowedPaths?.[topLevel.contract]
|
|
220
|
+
const route = paths?.length === 1 ? paths[0] : undefined
|
|
221
|
+
const pathText = route && route.length > 0 ? `; observed path: ${route.join(' -> ')}` : ''
|
|
222
|
+
warnings.push(
|
|
223
|
+
`SoroSwap swap: slippage / oracle price bound and exact hop path need the interpreter predicate (Path B)${pathText}`
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/synth/index.ts - re-export the synthesizer front-ends.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
type ComposeOptions,
|
|
5
|
+
type ComposeResult,
|
|
6
|
+
composeFromRecording,
|
|
7
|
+
} from './compose-from-recording.ts'
|
|
8
|
+
export { type IntentFacts, lower } from './lower.ts'
|
|
9
|
+
export {
|
|
10
|
+
type DecideScopeOptions,
|
|
11
|
+
decideScope,
|
|
12
|
+
type ScopeDecision,
|
|
13
|
+
scopeToContextRuleType,
|
|
14
|
+
} from './scope.ts'
|
|
15
|
+
export { synthesizeFromMandate } from './synthesize-from-mandate.ts'
|
|
16
|
+
export {
|
|
17
|
+
type SynthesizeFromRecordingOptions,
|
|
18
|
+
synthesizeFromRecording,
|
|
19
|
+
} from './synthesize-from-recording.ts'
|