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.
@@ -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 (deterministic). Supplying moves funds out; withdrawing back in.
143
- if (ctx.policy && kind === 'supply') {
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
- { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'scallop' },
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
- // deposit/withdraw auto-select the user's coins and return the resulting
158
- // coin to hand back to the owner. depositQuick's 3rd arg returnSCoin=false
159
- // keeps the old market-coin standard, which withdrawQuick can redeem (the
160
- // new sCoin standard is not found by withdrawQuick → "No valid coins").
161
- const out =
162
- kind === 'supply'
163
- ? await tx.depositQuick(Number(amountMist), 'sui', false)
164
- : await tx.withdrawQuick(Number(amountMist), 'sui')
165
- if (out) tx.transferObjects([out], ctx.agentAddress)
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
+ }