lyra-plugin-onchain 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/README.md +26 -0
- package/package.json +55 -0
- package/src/approval.ts +50 -0
- package/src/client.ts +32 -0
- package/src/derive.test.ts +45 -0
- package/src/derive.ts +39 -0
- package/src/guidance.ts +47 -0
- package/src/index.ts +94 -0
- package/src/policy.test.ts +187 -0
- package/src/policy.ts +267 -0
- package/src/protocols.ts +109 -0
- package/src/simulate.ts +60 -0
- package/src/tools/balance.ts +81 -0
- package/src/tools/cetus.ts +93 -0
- package/src/tools/deepbook.ts +60 -0
- package/src/tools/defillama.ts +89 -0
- package/src/tools/navi.ts +189 -0
- package/src/tools/policy.ts +180 -0
- package/src/tools/protocols.ts +38 -0
- package/src/tools/scallop.ts +217 -0
- package/src/tools/send.ts +121 -0
- package/src/tools/swap.ts +171 -0
- package/src/tools/walrus.ts +66 -0
- package/src/types.ts +32 -0
- package/src/vault.ts +76 -0
package/src/policy.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic policy engine — Lyra's "verifiable autonomy" core.
|
|
3
|
+
*
|
|
4
|
+
* The project rule (CLAUDE.md): the AI is advisory; fund controls are enforced
|
|
5
|
+
* in deterministic code, NOT by the model. Every write is checked here BEFORE
|
|
6
|
+
* it is simulated/broadcast. The verdict is a pure function of (action, policy)
|
|
7
|
+
* — no network, no model — so it is fully unit-testable and auditable. This is
|
|
8
|
+
* the off-chain mirror of the on-chain `lyra::policy::AgentPolicy`: the same
|
|
9
|
+
* caps, coin/protocol allowlists, and expiry are enforced again in Move so a
|
|
10
|
+
* compromised agent still cannot exceed them.
|
|
11
|
+
*
|
|
12
|
+
* Order of the write pipeline: policy → simulate → (approval) → execute → receipt.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 1 SUI = 10^9 MIST. */
|
|
16
|
+
export const MIST_PER_SUI = 1_000_000_000n
|
|
17
|
+
|
|
18
|
+
/** Canonical lowercased coin type for native SUI. */
|
|
19
|
+
const SUI_CANON = '0x2::sui::sui'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalize a coin type for comparison: lowercase, map the `native`/`sui`
|
|
23
|
+
* aliases to the canonical SUI type, and collapse a leading-zero-padded address
|
|
24
|
+
* (`0x000…02::sui::SUI`) to its short form (`0x2::sui::SUI`). The on-chain
|
|
25
|
+
* `type_name` form is fully padded; users write the short form — both compare
|
|
26
|
+
* equal here.
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeCoinType(input: string): string {
|
|
29
|
+
let t = input.trim().toLowerCase()
|
|
30
|
+
if (t === 'native' || t === 'sui') return SUI_CANON
|
|
31
|
+
const sep = t.indexOf('::')
|
|
32
|
+
if (sep > 0 && t.startsWith('0x')) {
|
|
33
|
+
const addr = t.slice(2, sep).replace(/^0+/, '') || '0'
|
|
34
|
+
t = `0x${addr}${t.slice(sep)}`
|
|
35
|
+
}
|
|
36
|
+
return t
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const lc = (a: string): string => a.trim().toLowerCase()
|
|
40
|
+
|
|
41
|
+
export interface SuiPolicy {
|
|
42
|
+
/** Reject every write (read-only agent). */
|
|
43
|
+
readOnly?: boolean
|
|
44
|
+
/** Hard cap on MIST moved per action. */
|
|
45
|
+
maxMistPerTx?: bigint
|
|
46
|
+
/** MIST at/under which the `auto` tier executes without approval. */
|
|
47
|
+
autoMaxMistPerTx?: bigint
|
|
48
|
+
/** If set, only these coin types may be moved/swapped (normalized compare). */
|
|
49
|
+
coinAllowlist?: string[]
|
|
50
|
+
/**
|
|
51
|
+
* If set, only these protocols may be touched. Values are short ids
|
|
52
|
+
* (`transfer`, `deepbook`, `walrus`) or package addresses. Empty = any.
|
|
53
|
+
*/
|
|
54
|
+
protocolAllowlist?: string[]
|
|
55
|
+
/** If set, transfers may only go to these recipient addresses. */
|
|
56
|
+
recipientAllowlist?: string[]
|
|
57
|
+
/** Max swap slippage tolerance, in basis points. */
|
|
58
|
+
maxSlippageBps?: number
|
|
59
|
+
/**
|
|
60
|
+
* Autonomy tier:
|
|
61
|
+
* - 'auto' execute within caps without asking
|
|
62
|
+
* - 'confirm' every write needs human approval
|
|
63
|
+
* - 'readonly' alias for readOnly=true
|
|
64
|
+
* A spend above `autoMaxMistPerTx` always escalates to approval.
|
|
65
|
+
*/
|
|
66
|
+
autonomy?: 'auto' | 'confirm' | 'readonly'
|
|
67
|
+
/** Absolute expiry (epoch ms). Past this, every write is blocked. */
|
|
68
|
+
expiryMs?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SuiPolicyAction {
|
|
72
|
+
kind: 'transfer' | 'swap'
|
|
73
|
+
/** Coin type of the INPUT asset. `native` / `sui` accepted as aliases. */
|
|
74
|
+
coinType: string
|
|
75
|
+
/** Amount in MIST. */
|
|
76
|
+
amountMist: bigint
|
|
77
|
+
/** Recipient (transfers only). */
|
|
78
|
+
to?: string
|
|
79
|
+
/** Swap OUTPUT coin type — checked against the coin allowlist. */
|
|
80
|
+
toCoinType?: string
|
|
81
|
+
/** Protocol touched (`transfer`, `deepbook`, `walrus`, or a package id). */
|
|
82
|
+
protocol?: string
|
|
83
|
+
/** Swap slippage tolerance in bps. */
|
|
84
|
+
slippageBps?: number
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PolicyVerdict {
|
|
88
|
+
/** Hard policy violations — if non-empty the action is BLOCKED. */
|
|
89
|
+
violations: string[]
|
|
90
|
+
/** True when the action is permitted to proceed (no violations). */
|
|
91
|
+
allowed: boolean
|
|
92
|
+
/** True when a permitted action still needs human approval before execution. */
|
|
93
|
+
requiresApproval: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Evaluate a proposed Sui action against the policy. Pure + deterministic.
|
|
98
|
+
* `nowMs` defaults to the wall clock but can be passed for deterministic tests.
|
|
99
|
+
*/
|
|
100
|
+
export function evaluatePolicy(
|
|
101
|
+
action: SuiPolicyAction,
|
|
102
|
+
policy: SuiPolicy,
|
|
103
|
+
nowMs: number = Date.now(),
|
|
104
|
+
): PolicyVerdict {
|
|
105
|
+
const violations: string[] = []
|
|
106
|
+
const readOnly = policy.readOnly || policy.autonomy === 'readonly'
|
|
107
|
+
if (readOnly) violations.push('policy is read-only: all writes are blocked')
|
|
108
|
+
|
|
109
|
+
// Coin allowlist — checks the input asset AND, for swaps, the OUTPUT asset,
|
|
110
|
+
// otherwise the agent could swap an allowed coin INTO an arbitrary one.
|
|
111
|
+
if (policy.coinAllowlist?.length) {
|
|
112
|
+
const allowed = policy.coinAllowlist.map(normalizeCoinType)
|
|
113
|
+
if (!allowed.includes(normalizeCoinType(action.coinType))) {
|
|
114
|
+
violations.push(`coin ${action.coinType} is not in the coin allowlist`)
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
action.kind === 'swap' &&
|
|
118
|
+
action.toCoinType !== undefined &&
|
|
119
|
+
!allowed.includes(normalizeCoinType(action.toCoinType))
|
|
120
|
+
) {
|
|
121
|
+
violations.push(`swap output coin ${action.toCoinType} is not in the coin allowlist`)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Protocol allowlist.
|
|
126
|
+
if (policy.protocolAllowlist?.length && action.protocol !== undefined) {
|
|
127
|
+
const allowed = policy.protocolAllowlist.map(lc)
|
|
128
|
+
if (!allowed.includes(lc(action.protocol))) {
|
|
129
|
+
violations.push(`protocol ${action.protocol} is not in the protocol allowlist`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Recipient allowlist (transfers).
|
|
134
|
+
if (policy.recipientAllowlist?.length && action.to) {
|
|
135
|
+
const allowed = policy.recipientAllowlist.map(lc)
|
|
136
|
+
if (!allowed.includes(lc(action.to))) {
|
|
137
|
+
violations.push(`recipient ${action.to} is not in the recipient allowlist`)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Per-tx amount cap.
|
|
142
|
+
if (policy.maxMistPerTx !== undefined && action.amountMist > policy.maxMistPerTx) {
|
|
143
|
+
violations.push(
|
|
144
|
+
`amount ${action.amountMist} MIST exceeds per-tx cap ${policy.maxMistPerTx} MIST`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Slippage cap (swaps).
|
|
149
|
+
if (
|
|
150
|
+
action.slippageBps !== undefined &&
|
|
151
|
+
policy.maxSlippageBps !== undefined &&
|
|
152
|
+
action.slippageBps > policy.maxSlippageBps
|
|
153
|
+
) {
|
|
154
|
+
violations.push(`slippage ${action.slippageBps} bps exceeds max ${policy.maxSlippageBps} bps`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Expiry.
|
|
158
|
+
if (policy.expiryMs !== undefined && nowMs > policy.expiryMs) {
|
|
159
|
+
violations.push(`policy expired at ${new Date(policy.expiryMs).toISOString()}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const allowed = violations.length === 0
|
|
163
|
+
|
|
164
|
+
// Approval gate: 'confirm' tier always needs approval; 'auto' escalates only
|
|
165
|
+
// when the spend is above the auto ceiling (material risk).
|
|
166
|
+
let requiresApproval = false
|
|
167
|
+
if (allowed) {
|
|
168
|
+
if (policy.autonomy === 'confirm') {
|
|
169
|
+
requiresApproval = true
|
|
170
|
+
} else if (
|
|
171
|
+
policy.autoMaxMistPerTx !== undefined &&
|
|
172
|
+
action.amountMist > policy.autoMaxMistPerTx
|
|
173
|
+
) {
|
|
174
|
+
requiresApproval = true
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { violations, allowed, requiresApproval }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Parse a decimal SUI string to MIST. Returns undefined for invalid input. */
|
|
182
|
+
export function suiToMist(sui?: string): bigint | undefined {
|
|
183
|
+
if (sui === undefined || sui === '') return undefined
|
|
184
|
+
const n = Number(sui)
|
|
185
|
+
if (!Number.isFinite(n) || n < 0) return undefined
|
|
186
|
+
// Round to whole MIST to avoid float drift for typical 1–9 decimal inputs.
|
|
187
|
+
return BigInt(Math.round(n * 1e9))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const list = (s?: string): string[] | undefined =>
|
|
191
|
+
s
|
|
192
|
+
? s
|
|
193
|
+
.split(',')
|
|
194
|
+
.map(x => x.trim())
|
|
195
|
+
.filter(Boolean)
|
|
196
|
+
: undefined
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Build a policy from environment variables (operator opt-in). Returns
|
|
200
|
+
* undefined when no policy env is set. `nowMs` seeds the relative expiry.
|
|
201
|
+
* LYRA_POLICY_READONLY=1
|
|
202
|
+
* LYRA_POLICY_MAX_PER_TX_SUI=1.0
|
|
203
|
+
* LYRA_POLICY_AUTO_MAX_SUI=0.1
|
|
204
|
+
* LYRA_POLICY_MAX_SLIPPAGE_BPS=100
|
|
205
|
+
* LYRA_POLICY_AUTONOMY=auto|confirm|readonly
|
|
206
|
+
* LYRA_POLICY_ALLOWED_PROTOCOLS=transfer,deepbook,walrus
|
|
207
|
+
* LYRA_POLICY_ALLOWED_COINS=0x2::sui::SUI
|
|
208
|
+
* LYRA_POLICY_RECIPIENT_ALLOWLIST=0xabc...,0xdef...
|
|
209
|
+
* LYRA_POLICY_EXPIRY_MINUTES=60
|
|
210
|
+
*/
|
|
211
|
+
export function policyFromEnv(
|
|
212
|
+
env: Record<string, string | undefined> = process.env,
|
|
213
|
+
nowMs: number = Date.now(),
|
|
214
|
+
): SuiPolicy | undefined {
|
|
215
|
+
const policy: SuiPolicy = {}
|
|
216
|
+
let any = false
|
|
217
|
+
|
|
218
|
+
if (env.LYRA_POLICY_READONLY === '1') {
|
|
219
|
+
policy.readOnly = true
|
|
220
|
+
any = true
|
|
221
|
+
}
|
|
222
|
+
const maxPerTx = suiToMist(env.LYRA_POLICY_MAX_PER_TX_SUI)
|
|
223
|
+
if (maxPerTx !== undefined) {
|
|
224
|
+
policy.maxMistPerTx = maxPerTx
|
|
225
|
+
any = true
|
|
226
|
+
}
|
|
227
|
+
const autoMax = suiToMist(env.LYRA_POLICY_AUTO_MAX_SUI)
|
|
228
|
+
if (autoMax !== undefined) {
|
|
229
|
+
policy.autoMaxMistPerTx = autoMax
|
|
230
|
+
any = true
|
|
231
|
+
}
|
|
232
|
+
if (env.LYRA_POLICY_MAX_SLIPPAGE_BPS) {
|
|
233
|
+
const bps = Number(env.LYRA_POLICY_MAX_SLIPPAGE_BPS)
|
|
234
|
+
if (Number.isFinite(bps) && bps >= 0) {
|
|
235
|
+
policy.maxSlippageBps = bps
|
|
236
|
+
any = true
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const autonomy = env.LYRA_POLICY_AUTONOMY
|
|
240
|
+
if (autonomy === 'auto' || autonomy === 'confirm' || autonomy === 'readonly') {
|
|
241
|
+
policy.autonomy = autonomy
|
|
242
|
+
any = true
|
|
243
|
+
}
|
|
244
|
+
const protocols = list(env.LYRA_POLICY_ALLOWED_PROTOCOLS)
|
|
245
|
+
if (protocols) {
|
|
246
|
+
policy.protocolAllowlist = protocols
|
|
247
|
+
any = true
|
|
248
|
+
}
|
|
249
|
+
const coins = list(env.LYRA_POLICY_ALLOWED_COINS)
|
|
250
|
+
if (coins) {
|
|
251
|
+
policy.coinAllowlist = coins
|
|
252
|
+
any = true
|
|
253
|
+
}
|
|
254
|
+
const recipients = list(env.LYRA_POLICY_RECIPIENT_ALLOWLIST)
|
|
255
|
+
if (recipients) {
|
|
256
|
+
policy.recipientAllowlist = recipients
|
|
257
|
+
any = true
|
|
258
|
+
}
|
|
259
|
+
if (env.LYRA_POLICY_EXPIRY_MINUTES) {
|
|
260
|
+
const mins = Number(env.LYRA_POLICY_EXPIRY_MINUTES)
|
|
261
|
+
if (Number.isFinite(mins) && mins > 0) {
|
|
262
|
+
policy.expiryMs = nowMs + mins * 60_000
|
|
263
|
+
any = true
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return any ? policy : undefined
|
|
267
|
+
}
|
package/src/protocols.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol capability registry — Lyra's honest map of what it can DO vs only SEE.
|
|
3
|
+
*
|
|
4
|
+
* Discovery is broad (DefiLlama indexes every Sui protocol), but execution is
|
|
5
|
+
* bounded to vetted adapters and the policy's protocol-allowlist. This registry
|
|
6
|
+
* is the single source of truth for that boundary, so when a user finds a great
|
|
7
|
+
* yield on a protocol Lyra has not integrated, Lyra says so honestly and offers
|
|
8
|
+
* what it CAN do — it never fabricates a transaction for a protocol it cannot
|
|
9
|
+
* actually reach.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type ProtocolCategory = 'clob' | 'dex' | 'aggregator' | 'lending' | 'staking' | 'storage'
|
|
13
|
+
|
|
14
|
+
export interface ProtocolCapability {
|
|
15
|
+
id: string
|
|
16
|
+
name: string
|
|
17
|
+
category: ProtocolCategory
|
|
18
|
+
/** Can Lyra read live data here (markets, prices, positions)? */
|
|
19
|
+
read: boolean
|
|
20
|
+
/** Can Lyra execute a state-changing action here (through the policy pipeline)? */
|
|
21
|
+
execute: boolean
|
|
22
|
+
/** Tool names that touch this protocol. */
|
|
23
|
+
tools: string[]
|
|
24
|
+
/** DefiLlama `project` slugs that map to this adapter (for executability tagging). */
|
|
25
|
+
llamaProjects?: string[]
|
|
26
|
+
note?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const PROTOCOLS: ProtocolCapability[] = [
|
|
30
|
+
{
|
|
31
|
+
id: 'walrus',
|
|
32
|
+
name: 'Walrus',
|
|
33
|
+
category: 'storage',
|
|
34
|
+
read: false,
|
|
35
|
+
execute: true,
|
|
36
|
+
tools: ['walrus.store'],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'deepbook',
|
|
40
|
+
name: 'DeepBook',
|
|
41
|
+
category: 'clob',
|
|
42
|
+
read: true,
|
|
43
|
+
execute: false,
|
|
44
|
+
tools: ['deepbook.markets'],
|
|
45
|
+
llamaProjects: ['deepbook'],
|
|
46
|
+
note: 'market data read; on-chain order execution not wired yet',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'scallop',
|
|
50
|
+
name: 'Scallop',
|
|
51
|
+
category: 'lending',
|
|
52
|
+
read: true,
|
|
53
|
+
execute: true,
|
|
54
|
+
tools: ['scallop.markets', 'scallop.position', 'scallop.supply', 'scallop.withdraw'],
|
|
55
|
+
llamaProjects: ['scallop-lending', 'scallop'],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'navi',
|
|
59
|
+
name: 'NAVI',
|
|
60
|
+
category: 'lending',
|
|
61
|
+
read: true,
|
|
62
|
+
execute: true,
|
|
63
|
+
tools: ['navi.markets', 'navi.position', 'navi.supply', 'navi.withdraw'],
|
|
64
|
+
llamaProjects: ['navi-lending', 'navi-protocol', 'navi'],
|
|
65
|
+
note: 'largest Sui lending market by TVL',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'aggregator',
|
|
69
|
+
name: 'DEX aggregator (7k / Cetus / FlowX / Bluefin)',
|
|
70
|
+
category: 'aggregator',
|
|
71
|
+
read: true,
|
|
72
|
+
execute: true,
|
|
73
|
+
tools: ['swap', 'cetus.quote'],
|
|
74
|
+
llamaProjects: [
|
|
75
|
+
'cetus-amm',
|
|
76
|
+
'cetus-clmm',
|
|
77
|
+
'cetus',
|
|
78
|
+
'flowx-finance',
|
|
79
|
+
'flowx-v3',
|
|
80
|
+
'bluefin-spot',
|
|
81
|
+
'bluefin',
|
|
82
|
+
'turbos',
|
|
83
|
+
'kriya-dex',
|
|
84
|
+
],
|
|
85
|
+
note: 'swap executes via the 7k aggregator, routing across Cetus/FlowX/Bluefin/DeepBook',
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
const PROJECT_INDEX: Record<string, ProtocolCapability> = (() => {
|
|
90
|
+
const idx: Record<string, ProtocolCapability> = {}
|
|
91
|
+
for (const p of PROTOCOLS) for (const slug of p.llamaProjects ?? []) idx[slug] = p
|
|
92
|
+
return idx
|
|
93
|
+
})()
|
|
94
|
+
|
|
95
|
+
/** Map a DefiLlama `project` slug to an integrated adapter, if any. */
|
|
96
|
+
export function adapterForProject(project: string): ProtocolCapability | null {
|
|
97
|
+
const key = project.trim().toLowerCase()
|
|
98
|
+
if (PROJECT_INDEX[key]) return PROJECT_INDEX[key]
|
|
99
|
+
// Loose contains-match: e.g. "scallop-lending" / "navi-lending".
|
|
100
|
+
for (const p of PROTOCOLS) {
|
|
101
|
+
if ((p.llamaProjects ?? []).some(s => key.includes(s) || s.includes(key))) return p
|
|
102
|
+
}
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Protocols Lyra can actually execute a state-changing action on. */
|
|
107
|
+
export function executableProtocols(): ProtocolCapability[] {
|
|
108
|
+
return PROTOCOLS.filter(p => p.execute)
|
|
109
|
+
}
|
package/src/simulate.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulate-before-write guard.
|
|
3
|
+
*
|
|
4
|
+
* Lyra's core safety rule (project thesis): every state-changing transaction is
|
|
5
|
+
* dry-run against the live chain BEFORE it is broadcast, so Move aborts and
|
|
6
|
+
* insufficient-funds are caught pre-flight and surfaced to the operator instead
|
|
7
|
+
* of burning gas on a doomed tx. Read-only — no transaction is sent here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { SuiClient } from '@mysten/sui/client'
|
|
11
|
+
import type { Transaction } from '@mysten/sui/transactions'
|
|
12
|
+
|
|
13
|
+
export interface SimOk {
|
|
14
|
+
ok: true
|
|
15
|
+
/** Net gas the (validated) transaction would cost, in MIST. */
|
|
16
|
+
gasUsed: string
|
|
17
|
+
}
|
|
18
|
+
export interface SimFail {
|
|
19
|
+
ok: false
|
|
20
|
+
/** Decoded Move abort / node error (truncated). */
|
|
21
|
+
reason: string
|
|
22
|
+
}
|
|
23
|
+
export type SimResult = SimOk | SimFail
|
|
24
|
+
|
|
25
|
+
/** Net gas = computation + storage − rebate, as a MIST string. */
|
|
26
|
+
function netGas(gas: {
|
|
27
|
+
computationCost: string
|
|
28
|
+
storageCost: string
|
|
29
|
+
storageRebate: string
|
|
30
|
+
}): string {
|
|
31
|
+
return (
|
|
32
|
+
BigInt(gas.computationCost) +
|
|
33
|
+
BigInt(gas.storageCost) -
|
|
34
|
+
BigInt(gas.storageRebate)
|
|
35
|
+
).toString()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Dry-run a PTB. `dryRunTransactionBlock` executes the block at the node
|
|
40
|
+
* (no broadcast), so Move aborts, type errors, and funding shortfalls surface
|
|
41
|
+
* here. Returns the decoded failure reason or the net gas estimate.
|
|
42
|
+
*/
|
|
43
|
+
export async function simulate(
|
|
44
|
+
client: SuiClient,
|
|
45
|
+
tx: Transaction,
|
|
46
|
+
sender: string,
|
|
47
|
+
): Promise<SimResult> {
|
|
48
|
+
try {
|
|
49
|
+
tx.setSenderIfNotSet(sender)
|
|
50
|
+
const bytes = await tx.build({ client })
|
|
51
|
+
const res = await client.dryRunTransactionBlock({ transactionBlock: bytes })
|
|
52
|
+
const status = res.effects.status
|
|
53
|
+
if (status.status !== 'success') {
|
|
54
|
+
return { ok: false, reason: status.error ?? 'transaction would fail' }
|
|
55
|
+
}
|
|
56
|
+
return { ok: true, gasUsed: netGas(res.effects.gasUsed) }
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return { ok: false, reason: (e as Error).message?.slice(0, 240) ?? 'unknown simulation error' }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only account tools: `sui.balance` and `account.info`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ToolDef } from 'lyra-core'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
8
|
+
|
|
9
|
+
const SUI_TYPE = '0x2::sui::SUI'
|
|
10
|
+
|
|
11
|
+
/** Format a MIST amount as a human SUI string. */
|
|
12
|
+
export function fmtSui(mist: string | bigint): string {
|
|
13
|
+
return (Number(BigInt(mist)) / 1e9).toLocaleString('en-US', { maximumFractionDigits: 6 })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const BalanceSchema = z.object({
|
|
17
|
+
address: z.string().optional().describe('Address to check. Defaults to the agent address.'),
|
|
18
|
+
})
|
|
19
|
+
type BalanceArgs = z.infer<typeof BalanceSchema>
|
|
20
|
+
|
|
21
|
+
export function makeSuiBalance(ctx: OnchainRuntimeContext): ToolDef<BalanceArgs> {
|
|
22
|
+
return {
|
|
23
|
+
name: 'sui.balance',
|
|
24
|
+
description:
|
|
25
|
+
"Show SUI and other coin balances for the agent (or a given address). Read-only. Use for 'what's my balance', 'how much SUI do I have'.",
|
|
26
|
+
searchHint: 'balance sui holdings funds wallet how much do i have coins',
|
|
27
|
+
schema: BalanceSchema,
|
|
28
|
+
handler: async args => {
|
|
29
|
+
try {
|
|
30
|
+
const owner = args.address?.trim() || ctx.agentAddress
|
|
31
|
+
const sui = await ctx.client.getBalance({ owner, coinType: SUI_TYPE })
|
|
32
|
+
const all = await ctx.client.getAllBalances({ owner })
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
data: {
|
|
36
|
+
address: owner,
|
|
37
|
+
network: ctx.network,
|
|
38
|
+
sui: { mist: sui.totalBalance, formatted: `${fmtSui(sui.totalBalance)} SUI` },
|
|
39
|
+
coins: all
|
|
40
|
+
.filter(b => b.coinType !== SUI_TYPE && BigInt(b.totalBalance) > 0n)
|
|
41
|
+
.map(b => ({ coinType: b.coinType, balance: b.totalBalance })),
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const InfoSchema = z.object({})
|
|
52
|
+
type InfoArgs = z.infer<typeof InfoSchema>
|
|
53
|
+
|
|
54
|
+
export function makeAccountInfo(ctx: OnchainRuntimeContext): ToolDef<InfoArgs> {
|
|
55
|
+
return {
|
|
56
|
+
name: 'account.info',
|
|
57
|
+
description:
|
|
58
|
+
"The agent's on-chain identity: Sui address, network, SUI balance, the lyra::policy package + policy object in force, and whether a deterministic policy is armed. Read-only.",
|
|
59
|
+
searchHint: 'account identity who am i address agent network policy package',
|
|
60
|
+
schema: InfoSchema,
|
|
61
|
+
handler: async () => {
|
|
62
|
+
try {
|
|
63
|
+
const sui = await ctx.client.getBalance({ owner: ctx.agentAddress, coinType: SUI_TYPE })
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
data: {
|
|
67
|
+
agentAddress: ctx.agentAddress,
|
|
68
|
+
network: ctx.network,
|
|
69
|
+
sui: `${fmtSui(sui.totalBalance)} SUI`,
|
|
70
|
+
policyPackage: ctx.packageId ?? null,
|
|
71
|
+
policyObject: ctx.policyObjectId ?? null,
|
|
72
|
+
policyArmed: ctx.policy != null,
|
|
73
|
+
brainModel: ctx.brainModel ?? null,
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cetus.quote` — best-execution price discovery across Cetus (Sui's largest
|
|
3
|
+
* DEX) via the Cetus aggregator. Read-only: it finds the best route and output
|
|
4
|
+
* for a swap so the agent can compare venues (e.g. vs DeepBook) before acting.
|
|
5
|
+
*
|
|
6
|
+
* Note: on-chain swap execution is intentionally NOT wired here. The Cetus
|
|
7
|
+
* aggregator SDK builds transactions with @mysten/sui v2, which is incompatible
|
|
8
|
+
* with this stack's v1 policy → simulate → execute pipeline. Quoting uses the
|
|
9
|
+
* aggregator's router with our v1 client; executing a Cetus swap would require
|
|
10
|
+
* migrating the whole stack to SDK v2.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { AggregatorClient } from '@cetusprotocol/aggregator-sdk'
|
|
14
|
+
import type { ToolDef } from 'lyra-core'
|
|
15
|
+
import { z } from 'zod'
|
|
16
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
17
|
+
|
|
18
|
+
/** Symbol → mainnet coin type + decimals for the common assets. */
|
|
19
|
+
const COINS: Record<string, { type: string; decimals: number }> = {
|
|
20
|
+
sui: { type: '0x2::sui::SUI', decimals: 9 },
|
|
21
|
+
usdc: {
|
|
22
|
+
type: '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC',
|
|
23
|
+
decimals: 6,
|
|
24
|
+
},
|
|
25
|
+
deep: {
|
|
26
|
+
type: '0xdeeb7a4662eec9f2f3def03fb937a663dddaa2e215b8078a284d026b7946c270::deep::DEEP',
|
|
27
|
+
decimals: 6,
|
|
28
|
+
},
|
|
29
|
+
wal: {
|
|
30
|
+
type: '0x356a26eb9e012a68958082340d4c4116e7f55615cf27affcff209cf0ae544f59::wal::WAL',
|
|
31
|
+
decimals: 9,
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolve(input: string): { type: string; decimals: number } {
|
|
36
|
+
const k = input.trim().toLowerCase()
|
|
37
|
+
if (COINS[k]) return COINS[k]
|
|
38
|
+
return { type: input.trim(), decimals: 9 }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const Schema = z.object({
|
|
42
|
+
from: z.string().min(1).describe('Input coin: symbol (sui, usdc, deep, wal) or full coin type.'),
|
|
43
|
+
to: z.string().min(1).describe('Output coin: symbol or full coin type.'),
|
|
44
|
+
amount: z.string().min(1).describe('Input amount in whole units of `from`, e.g. "1.5".'),
|
|
45
|
+
})
|
|
46
|
+
type Args = z.infer<typeof Schema>
|
|
47
|
+
|
|
48
|
+
export function makeCetusQuote(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
49
|
+
return {
|
|
50
|
+
name: 'cetus.quote',
|
|
51
|
+
description:
|
|
52
|
+
'Quote the best Cetus swap route on Sui (read-only): input → output amount and implied price across Cetus pools. Use to compare execution venues before proposing a swap. Does not execute.',
|
|
53
|
+
searchHint: 'cetus swap quote price route dex best execution exchange convert',
|
|
54
|
+
schema: Schema,
|
|
55
|
+
handler: async args => {
|
|
56
|
+
if (ctx.network !== 'mainnet')
|
|
57
|
+
return { ok: false, error: 'Cetus aggregator supports mainnet only' }
|
|
58
|
+
try {
|
|
59
|
+
const from = resolve(args.from)
|
|
60
|
+
const to = resolve(args.to)
|
|
61
|
+
const amountIn = BigInt(Math.round(Number(args.amount) * 10 ** from.decimals))
|
|
62
|
+
if (amountIn <= 0n) return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
63
|
+
|
|
64
|
+
// The aggregator accepts our v1 client for read-only routing.
|
|
65
|
+
const agg = new AggregatorClient({ signer: ctx.agentAddress, client: ctx.client as never })
|
|
66
|
+
const route = await agg.findRouters({
|
|
67
|
+
from: from.type,
|
|
68
|
+
target: to.type,
|
|
69
|
+
amount: amountIn,
|
|
70
|
+
byAmountIn: true,
|
|
71
|
+
})
|
|
72
|
+
if (!route || route.amountOut == null) return { ok: false, error: 'no Cetus route found' }
|
|
73
|
+
|
|
74
|
+
const out = BigInt(route.amountOut.toString())
|
|
75
|
+
const price = Number(out) / 10 ** to.decimals / (Number(amountIn) / 10 ** from.decimals)
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
data: {
|
|
79
|
+
venue: 'cetus',
|
|
80
|
+
from: args.from,
|
|
81
|
+
to: args.to,
|
|
82
|
+
amountIn: args.amount,
|
|
83
|
+
amountOut: (Number(out) / 10 ** to.decimals).toString(),
|
|
84
|
+
price: `${price.toPrecision(6)} ${args.to}/${args.from}`,
|
|
85
|
+
note: 'read-only quote; Cetus swap execution not wired (SDK v2 vs stack v1)',
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `deepbook.markets` — read DeepBook spot market data on Sui (mid prices for the
|
|
3
|
+
* core pools). Read-only discovery; never moves funds.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DeepBookClient } from '@mysten/deepbook-v3'
|
|
7
|
+
import type { ToolDef } from 'lyra-core'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
10
|
+
|
|
11
|
+
/** Core mainnet pool keys shipped with the DeepBook SDK config. */
|
|
12
|
+
const DEFAULT_POOLS = ['SUI_USDC', 'DEEP_USDC', 'DEEP_SUI', 'WAL_USDC', 'WAL_SUI']
|
|
13
|
+
|
|
14
|
+
const Schema = z.object({
|
|
15
|
+
pools: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('Comma-separated pool keys (e.g. "SUI_USDC,DEEP_USDC"). Omit for the core set.'),
|
|
19
|
+
})
|
|
20
|
+
type Args = z.infer<typeof Schema>
|
|
21
|
+
|
|
22
|
+
export function makeDeepbookMarkets(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
23
|
+
return {
|
|
24
|
+
name: 'deepbook.markets',
|
|
25
|
+
description:
|
|
26
|
+
'Read DeepBook spot market data on Sui: mid price for the core pools (SUI/USDC, DEEP/USDC, WAL/USDC, ...). Read-only market context for execution.',
|
|
27
|
+
searchHint: 'deepbook market price pool spot orderbook mid quote sui usdc liquidity',
|
|
28
|
+
schema: Schema,
|
|
29
|
+
handler: async args => {
|
|
30
|
+
try {
|
|
31
|
+
const db = new DeepBookClient({
|
|
32
|
+
// cast: @mysten/deepbook-v3 bundles a different @mysten/sui minor; the
|
|
33
|
+
// client is runtime-compatible (verified live) but not type-identical.
|
|
34
|
+
client: ctx.client as never,
|
|
35
|
+
address: ctx.agentAddress,
|
|
36
|
+
env: ctx.network,
|
|
37
|
+
})
|
|
38
|
+
const keys = args.pools
|
|
39
|
+
? args.pools
|
|
40
|
+
.split(',')
|
|
41
|
+
.map(s => s.trim())
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
: DEFAULT_POOLS
|
|
44
|
+
const markets = await Promise.all(
|
|
45
|
+
keys.map(async pool => {
|
|
46
|
+
try {
|
|
47
|
+
const mid = await db.midPrice(pool)
|
|
48
|
+
return { pool, midPrice: mid }
|
|
49
|
+
} catch (e) {
|
|
50
|
+
return { pool, error: (e as Error).message.slice(0, 120) }
|
|
51
|
+
}
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
return { ok: true, data: { network: ctx.network, markets } }
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|