lyra-plugin-onchain 0.1.5 → 0.1.8
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/package.json +6 -17
- package/src/index.ts +34 -1
- package/src/minimums.test.ts +36 -0
- package/src/minimums.ts +35 -0
- package/src/protocols.ts +60 -6
- package/src/tools/liquid-stake.ts +152 -0
- package/src/tools/navi.ts +48 -5
- package/src/tools/onchain.integration.test.ts +111 -0
- package/src/tools/scallop.ts +47 -13
- package/src/tools/send.ts +3 -0
- package/src/tools/stake.ts +209 -0
- package/src/tools/suilend.ts +419 -0
- package/src/tools/swap.ts +6 -0
- package/src/tools/tools.test.ts +142 -0
package/src/tools/scallop.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { Scallop } from '@scallop-io/sui-scallop-sdk'
|
|
13
13
|
import type { ToolDef } from 'lyra-core'
|
|
14
14
|
import { z } from 'zod'
|
|
15
|
+
import { checkMinimum } from '../minimums'
|
|
15
16
|
import { evaluatePolicy, suiToMist } from '../policy'
|
|
16
17
|
import { simulate } from '../simulate'
|
|
17
18
|
import type { OnchainRuntimeContext } from '../types'
|
|
@@ -131,18 +132,29 @@ type AmountArgs = z.infer<typeof AmountSchema>
|
|
|
131
132
|
async function runScallopWrite(
|
|
132
133
|
ctx: OnchainRuntimeContext,
|
|
133
134
|
amount: string,
|
|
134
|
-
kind: 'supply' | 'withdraw',
|
|
135
|
+
kind: 'supply' | 'withdraw' | 'borrow' | 'repay',
|
|
135
136
|
): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> {
|
|
136
137
|
const err = ensureMainnet(ctx)
|
|
137
138
|
if (err) return { ok: false, error: err }
|
|
138
139
|
const amountMist = suiToMist(amount)
|
|
139
140
|
if (amountMist === undefined || amountMist <= 0n)
|
|
140
141
|
return { ok: false, error: `invalid amount "${amount}"` }
|
|
142
|
+
// Minimum guard for value-moving actions (withdraw pulls the agent's own funds).
|
|
143
|
+
const minAction = kind === 'borrow' ? 'borrow' : kind === 'withdraw' ? null : 'supply'
|
|
144
|
+
if (minAction) {
|
|
145
|
+
const tooSmall = checkMinimum(minAction, amountMist)
|
|
146
|
+
if (tooSmall) return { ok: false, error: tooSmall }
|
|
147
|
+
}
|
|
141
148
|
|
|
142
|
-
// Policy gate (
|
|
143
|
-
if (ctx.policy && kind
|
|
149
|
+
// Policy gate on value-moving actions (borrow creates debt + hands out funds).
|
|
150
|
+
if (ctx.policy && kind !== 'withdraw') {
|
|
144
151
|
const verdict = evaluatePolicy(
|
|
145
|
-
{
|
|
152
|
+
{
|
|
153
|
+
kind: 'transfer',
|
|
154
|
+
coinType: SUI_TYPE,
|
|
155
|
+
amountMist,
|
|
156
|
+
protocol: kind === 'borrow' ? 'borrow' : 'scallop',
|
|
157
|
+
},
|
|
146
158
|
ctx.policy,
|
|
147
159
|
)
|
|
148
160
|
if (!verdict.allowed)
|
|
@@ -154,15 +166,16 @@ async function runScallopWrite(
|
|
|
154
166
|
const builder = await sdk.createScallopBuilder()
|
|
155
167
|
const tx = builder.createTxBlock()
|
|
156
168
|
tx.setSender(ctx.agentAddress)
|
|
157
|
-
//
|
|
158
|
-
// coin to hand back
|
|
159
|
-
// keeps the old market-coin standard
|
|
160
|
-
// new sCoin standard
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
169
|
+
// *Quick helpers auto-select the user's coins/obligation. supply/withdraw and
|
|
170
|
+
// borrow return a coin to hand back; repay consumes coins. depositQuick's 3rd
|
|
171
|
+
// arg returnSCoin=false keeps the old market-coin standard (withdrawQuick
|
|
172
|
+
// redeems it; the new sCoin standard → "No valid coins").
|
|
173
|
+
let out: unknown
|
|
174
|
+
if (kind === 'supply') out = await tx.depositQuick(Number(amountMist), 'sui', false)
|
|
175
|
+
else if (kind === 'withdraw') out = await tx.withdrawQuick(Number(amountMist), 'sui')
|
|
176
|
+
else if (kind === 'borrow') out = await tx.borrowQuick(Number(amountMist), 'sui')
|
|
177
|
+
else await tx.repayQuick(Number(amountMist), 'sui')
|
|
178
|
+
if (out) tx.transferObjects([out as never], ctx.agentAddress)
|
|
166
179
|
const transaction = tx.txBlock
|
|
167
180
|
|
|
168
181
|
const sim = await simulate(ctx.client, transaction, ctx.agentAddress)
|
|
@@ -215,3 +228,24 @@ export function makeScallopWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountA
|
|
|
215
228
|
handler: async args => runScallopWrite(ctx, args.amount, 'withdraw'),
|
|
216
229
|
}
|
|
217
230
|
}
|
|
231
|
+
|
|
232
|
+
export function makeScallopBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
233
|
+
return {
|
|
234
|
+
name: 'scallop.borrow',
|
|
235
|
+
description:
|
|
236
|
+
'Borrow SUI from Scallop against supplied collateral. Requires an existing supply position with enough health; the pre-flight simulation fails cleanly if under-collateralized. Policy-checked, simulated, then executed.',
|
|
237
|
+
searchHint: 'scallop borrow loan leverage debt against collateral sui',
|
|
238
|
+
schema: AmountSchema,
|
|
239
|
+
handler: async args => runScallopWrite(ctx, args.amount, 'borrow'),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function makeScallopRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
244
|
+
return {
|
|
245
|
+
name: 'scallop.repay',
|
|
246
|
+
description: 'Repay borrowed SUI debt on Scallop. Simulated, then executed.',
|
|
247
|
+
searchHint: 'scallop repay pay back debt loan close sui',
|
|
248
|
+
schema: AmountSchema,
|
|
249
|
+
handler: async args => runScallopWrite(ctx, args.amount, 'repay'),
|
|
250
|
+
}
|
|
251
|
+
}
|
package/src/tools/send.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { Transaction } from '@mysten/sui/transactions'
|
|
12
12
|
import type { ToolDef } from 'lyra-core'
|
|
13
13
|
import { z } from 'zod'
|
|
14
|
+
import { checkMinimum } from '../minimums'
|
|
14
15
|
import { evaluatePolicy, suiToMist } from '../policy'
|
|
15
16
|
import { simulate } from '../simulate'
|
|
16
17
|
import type { OnchainRuntimeContext } from '../types'
|
|
@@ -40,6 +41,8 @@ export function makeSuiSend(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
|
40
41
|
if (amountMist === undefined || amountMist <= 0n) {
|
|
41
42
|
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
42
43
|
}
|
|
44
|
+
const tooSmall = checkMinimum('transfer', amountMist)
|
|
45
|
+
if (tooSmall) return { ok: false, error: tooSmall }
|
|
43
46
|
|
|
44
47
|
// 1. Policy gate (deterministic) — block before simulate/execute.
|
|
45
48
|
if (ctx.policy) {
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sui native staking — delegate SUI to a validator and unstake.
|
|
3
|
+
*
|
|
4
|
+
* sui.stake → 0x3::sui_system::request_add_stake (min 1 SUI, hard on-chain)
|
|
5
|
+
* sui.unstake → 0x3::sui_system::request_withdraw_stake
|
|
6
|
+
*
|
|
7
|
+
* Same guarded pipeline as the other write tools: minimum-amount guard →
|
|
8
|
+
* deterministic policy → dry-run simulate → execute → on-chain effects check.
|
|
9
|
+
* Sui native staking rejects anything below 1 SUI, so a too-small amount returns
|
|
10
|
+
* a clear "amount too small" error BEFORE any transaction is built.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Transaction } from '@mysten/sui/transactions'
|
|
14
|
+
import type { ToolDef } from 'lyra-core'
|
|
15
|
+
import { z } from 'zod'
|
|
16
|
+
import { checkMinimum } from '../minimums'
|
|
17
|
+
import { evaluatePolicy, suiToMist } from '../policy'
|
|
18
|
+
import { simulate } from '../simulate'
|
|
19
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
20
|
+
|
|
21
|
+
const SUI_TYPE = '0x2::sui::SUI'
|
|
22
|
+
const SUI_SYSTEM_STATE = '0x5'
|
|
23
|
+
const STAKED_SUI_TYPE = '0x3::staking_pool::StakedSui'
|
|
24
|
+
|
|
25
|
+
/** Resolve a validator: match `want` by address/name, else pick a large active one. */
|
|
26
|
+
async function resolveValidator(
|
|
27
|
+
client: OnchainRuntimeContext['client'],
|
|
28
|
+
want?: string,
|
|
29
|
+
): Promise<{ address: string; name: string } | null> {
|
|
30
|
+
const state = await client.getLatestSuiSystemState()
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: SuiSystemState validator fields
|
|
32
|
+
const vals = (state.activeValidators ?? []) as any[]
|
|
33
|
+
if (vals.length === 0) return null
|
|
34
|
+
if (want?.trim()) {
|
|
35
|
+
const w = want.trim().toLowerCase()
|
|
36
|
+
const m = vals.find(
|
|
37
|
+
v => String(v.suiAddress).toLowerCase() === w || String(v.name ?? '').toLowerCase() === w,
|
|
38
|
+
)
|
|
39
|
+
if (m) return { address: m.suiAddress, name: m.name ?? '' }
|
|
40
|
+
// A raw 0x address the user gave is honored even if not in the top set.
|
|
41
|
+
if (w.startsWith('0x')) return { address: want.trim(), name: '' }
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
// Default: the validator with the most voting power (a large, reliable one).
|
|
45
|
+
const best = vals.reduce((a, b) => (Number(b.votingPower) > Number(a.votingPower) ? b : a))
|
|
46
|
+
return { address: best.suiAddress, name: best.name ?? '' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const StakeSchema = z.object({
|
|
50
|
+
amount: z.string().min(1).describe('Amount of SUI to stake (minimum 1 SUI).'),
|
|
51
|
+
validator: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe('Validator address (0x…) or name. Optional — defaults to a large active validator.'),
|
|
55
|
+
})
|
|
56
|
+
type StakeArgs = z.infer<typeof StakeSchema>
|
|
57
|
+
|
|
58
|
+
export function makeStake(ctx: OnchainRuntimeContext): ToolDef<StakeArgs> {
|
|
59
|
+
return {
|
|
60
|
+
name: 'sui.stake',
|
|
61
|
+
description:
|
|
62
|
+
'Stake SUI to a validator (Sui native staking) to earn staking rewards. Minimum 1 SUI (enforced by the network). Policy-checked → simulated → executed; returns a StakedSui object.',
|
|
63
|
+
searchHint: 'stake staking delegate validator earn rewards yield sui native',
|
|
64
|
+
schema: StakeSchema,
|
|
65
|
+
handler: async args => {
|
|
66
|
+
try {
|
|
67
|
+
const amountMist = suiToMist(args.amount)
|
|
68
|
+
if (amountMist === undefined || amountMist <= 0n) {
|
|
69
|
+
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
70
|
+
}
|
|
71
|
+
const tooSmall = checkMinimum('stake', amountMist)
|
|
72
|
+
if (tooSmall) return { ok: false, error: tooSmall }
|
|
73
|
+
|
|
74
|
+
if (ctx.policy) {
|
|
75
|
+
const verdict = evaluatePolicy(
|
|
76
|
+
{ kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'stake' },
|
|
77
|
+
ctx.policy,
|
|
78
|
+
)
|
|
79
|
+
if (!verdict.allowed) {
|
|
80
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const validator = await resolveValidator(ctx.client, args.validator)
|
|
85
|
+
if (!validator) return { ok: false, error: 'no active validator found to stake with' }
|
|
86
|
+
|
|
87
|
+
const tx = new Transaction()
|
|
88
|
+
const [coin] = tx.splitCoins(tx.gas, [amountMist])
|
|
89
|
+
tx.moveCall({
|
|
90
|
+
target: '0x3::sui_system::request_add_stake',
|
|
91
|
+
arguments: [tx.object(SUI_SYSTEM_STATE), coin, tx.pure.address(validator.address)],
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
95
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
96
|
+
|
|
97
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
98
|
+
signer: ctx.keypair,
|
|
99
|
+
transaction: tx,
|
|
100
|
+
options: { showEffects: true, showObjectChanges: true },
|
|
101
|
+
})
|
|
102
|
+
if (res.effects?.status?.status !== 'success') {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
109
|
+
const staked = res.objectChanges?.find(
|
|
110
|
+
c =>
|
|
111
|
+
c.type === 'created' &&
|
|
112
|
+
String((c as { objectType?: string }).objectType).includes('staking_pool::StakedSui'),
|
|
113
|
+
) as { objectId?: string } | undefined
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
data: {
|
|
118
|
+
protocol: 'sui-staking',
|
|
119
|
+
action: 'stake',
|
|
120
|
+
amountSui: args.amount,
|
|
121
|
+
validator: validator.address,
|
|
122
|
+
validatorName: validator.name || undefined,
|
|
123
|
+
digest: res.digest,
|
|
124
|
+
stakedSuiId: staked?.objectId ?? null,
|
|
125
|
+
status: 'success',
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const UnstakeSchema = z.object({
|
|
136
|
+
stakedSuiId: z
|
|
137
|
+
.string()
|
|
138
|
+
.optional()
|
|
139
|
+
.describe('StakedSui object id to withdraw. Optional — defaults to the first staked position.'),
|
|
140
|
+
})
|
|
141
|
+
type UnstakeArgs = z.infer<typeof UnstakeSchema>
|
|
142
|
+
|
|
143
|
+
export function makeUnstake(ctx: OnchainRuntimeContext): ToolDef<UnstakeArgs> {
|
|
144
|
+
return {
|
|
145
|
+
name: 'sui.unstake',
|
|
146
|
+
description:
|
|
147
|
+
'Withdraw staked SUI (unstake) from a validator, returning principal + earned rewards. Uses a specific StakedSui object id, or the first staked position if omitted.',
|
|
148
|
+
searchHint: 'unstake withdraw staking redeem claim rewards validator',
|
|
149
|
+
schema: UnstakeSchema,
|
|
150
|
+
handler: async args => {
|
|
151
|
+
try {
|
|
152
|
+
let stakedId = args.stakedSuiId?.trim()
|
|
153
|
+
if (!stakedId) {
|
|
154
|
+
const owned = await ctx.client.getOwnedObjects({
|
|
155
|
+
owner: ctx.agentAddress,
|
|
156
|
+
filter: { StructType: STAKED_SUI_TYPE },
|
|
157
|
+
})
|
|
158
|
+
stakedId = owned.data[0]?.data?.objectId
|
|
159
|
+
if (!stakedId) return { ok: false, error: 'no staked SUI position found to unstake' }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (ctx.policy) {
|
|
163
|
+
const verdict = evaluatePolicy(
|
|
164
|
+
{ kind: 'transfer', coinType: SUI_TYPE, amountMist: 0n, protocol: 'stake' },
|
|
165
|
+
ctx.policy,
|
|
166
|
+
)
|
|
167
|
+
if (!verdict.allowed) {
|
|
168
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const tx = new Transaction()
|
|
173
|
+
tx.moveCall({
|
|
174
|
+
target: '0x3::sui_system::request_withdraw_stake',
|
|
175
|
+
arguments: [tx.object(SUI_SYSTEM_STATE), tx.object(stakedId)],
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
179
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
180
|
+
|
|
181
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
182
|
+
signer: ctx.keypair,
|
|
183
|
+
transaction: tx,
|
|
184
|
+
options: { showEffects: true },
|
|
185
|
+
})
|
|
186
|
+
if (res.effects?.status?.status !== 'success') {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
data: {
|
|
197
|
+
protocol: 'sui-staking',
|
|
198
|
+
action: 'unstake',
|
|
199
|
+
stakedSuiId: stakedId,
|
|
200
|
+
digest: res.digest,
|
|
201
|
+
status: 'success',
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
} catch (e) {
|
|
205
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
}
|