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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lyra-plugin-onchain",
3
- "version": "0.1.5",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "description": "The Sui on-chain tools for Lyra: SUI transfers, DeepBook market data, Walrus receipts/memory — every write policy-checked, simulated, and recorded on-chain via lyra::policy",
6
6
  "license": "MIT",
@@ -13,28 +13,14 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/rifkyeasy/lyra/issues"
15
15
  },
16
- "keywords": [
17
- "lyra",
18
- "ai",
19
- "agent",
20
- "sui",
21
- "defi",
22
- "deepbook",
23
- "walrus",
24
- "policy",
25
- "plugin"
26
- ],
16
+ "keywords": ["lyra", "ai", "agent", "sui", "defi", "deepbook", "walrus", "policy", "plugin"],
27
17
  "publishConfig": {
28
18
  "access": "public"
29
19
  },
30
20
  "engines": {
31
21
  "bun": ">=1.1"
32
22
  },
33
- "files": [
34
- "src",
35
- "!src/**/*.test.ts",
36
- "README.md"
37
- ],
23
+ "files": ["src", "!src/**/*.test.ts", "README.md"],
38
24
  "main": "./src/index.ts",
39
25
  "types": "./src/index.ts",
40
26
  "scripts": {
@@ -47,7 +33,10 @@
47
33
  "@mysten/deepbook-v3": "^0.18.0",
48
34
  "@mysten/sui": "^1.40.0",
49
35
  "@mysten/walrus": "^0.6.4",
36
+ "@pythnetwork/pyth-sui-js": "2.2.0",
50
37
  "@scallop-io/sui-scallop-sdk": "^2.4.5",
38
+ "@suilend/sdk": "1.1.99",
39
+ "@suilend/sui-fe": "0.3.49",
51
40
  "lyra-core": "^0.1.2",
52
41
  "navi-sdk": "^1.7.3",
53
42
  "zod": "^3.23.8"
package/src/index.ts CHANGED
@@ -25,16 +25,34 @@ import { makeAccountInfo, makeSuiBalance } from './tools/balance'
25
25
  import { makeCetusQuote } from './tools/cetus'
26
26
  import { makeDeepbookMarkets } from './tools/deepbook'
27
27
  import { makeDefiYields } from './tools/defillama'
28
- import { makeNaviMarkets, makeNaviPosition, makeNaviSupply, makeNaviWithdraw } from './tools/navi'
28
+ import { makeVoloStake, makeVoloUnstake } from './tools/liquid-stake'
29
+ import {
30
+ makeNaviBorrow,
31
+ makeNaviMarkets,
32
+ makeNaviPosition,
33
+ makeNaviRepay,
34
+ makeNaviSupply,
35
+ makeNaviWithdraw,
36
+ } from './tools/navi'
29
37
  import { makePolicyCreate, makePolicyShow } from './tools/policy'
30
38
  import { makeProtocolsList } from './tools/protocols'
31
39
  import {
40
+ makeScallopBorrow,
32
41
  makeScallopMarkets,
33
42
  makeScallopPosition,
43
+ makeScallopRepay,
34
44
  makeScallopSupply,
35
45
  makeScallopWithdraw,
36
46
  } from './tools/scallop'
37
47
  import { makeSuiSend } from './tools/send'
48
+ import { makeStake, makeUnstake } from './tools/stake'
49
+ import {
50
+ makeSuilendBorrow,
51
+ makeSuilendPosition,
52
+ makeSuilendRepay,
53
+ makeSuilendSupply,
54
+ makeSuilendWithdraw,
55
+ } from './tools/suilend'
38
56
  import { makeSwap } from './tools/swap'
39
57
  import { makeWalrusStore } from './tools/walrus'
40
58
  import type { OnchainRuntimeContext } from './types'
@@ -87,10 +105,25 @@ const plugin: NativePlugin = {
87
105
  ctx.registerTool(makeScallopPosition(onchain) as ToolDef)
88
106
  ctx.registerTool(makeScallopSupply(onchain) as ToolDef)
89
107
  ctx.registerTool(makeScallopWithdraw(onchain) as ToolDef)
108
+ ctx.registerTool(makeScallopBorrow(onchain) as ToolDef)
109
+ ctx.registerTool(makeScallopRepay(onchain) as ToolDef)
90
110
  ctx.registerTool(makeNaviMarkets(onchain) as ToolDef)
91
111
  ctx.registerTool(makeNaviPosition(onchain) as ToolDef)
92
112
  ctx.registerTool(makeNaviSupply(onchain) as ToolDef)
93
113
  ctx.registerTool(makeNaviWithdraw(onchain) as ToolDef)
114
+ ctx.registerTool(makeNaviBorrow(onchain) as ToolDef)
115
+ ctx.registerTool(makeNaviRepay(onchain) as ToolDef)
116
+ ctx.registerTool(makeSuilendSupply(onchain) as ToolDef)
117
+ ctx.registerTool(makeSuilendWithdraw(onchain) as ToolDef)
118
+ ctx.registerTool(makeSuilendBorrow(onchain) as ToolDef)
119
+ ctx.registerTool(makeSuilendRepay(onchain) as ToolDef)
120
+ ctx.registerTool(makeSuilendPosition(onchain) as ToolDef)
121
+
122
+ // Staking: native delegation (min 1 SUI) + Volo liquid staking (vSUI).
123
+ ctx.registerTool(makeStake(onchain) as ToolDef)
124
+ ctx.registerTool(makeUnstake(onchain) as ToolDef)
125
+ ctx.registerTool(makeVoloStake(onchain) as ToolDef)
126
+ ctx.registerTool(makeVoloUnstake(onchain) as ToolDef)
94
127
  },
95
128
  }
96
129
 
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { MIN_MIST, checkMinimum } from './minimums'
3
+
4
+ describe('checkMinimum', () => {
5
+ test('rejects a too-small stake (0.1 SUI < 1 SUI minimum)', () => {
6
+ const err = checkMinimum('stake', 100_000_000n) // 0.1 SUI
7
+ expect(err).not.toBeNull()
8
+ expect(err).toContain('amount too small')
9
+ expect(err).toContain('1 SUI minimum for stake')
10
+ })
11
+
12
+ test('accepts a stake at the 1 SUI minimum', () => {
13
+ expect(checkMinimum('stake', MIN_MIST.stake)).toBeNull()
14
+ expect(checkMinimum('stake', 2_000_000_000n)).toBeNull()
15
+ })
16
+
17
+ test('rejects dust transfers/swaps/supplies below their minimums', () => {
18
+ expect(checkMinimum('transfer', 1n)).not.toBeNull()
19
+ expect(checkMinimum('swap', 1_000_000n)).not.toBeNull() // 0.001 < 0.01
20
+ expect(checkMinimum('supply', 5_000_000n)).not.toBeNull() // 0.005 < 0.01
21
+ expect(checkMinimum('borrow', 9_999_999n)).not.toBeNull()
22
+ })
23
+
24
+ test('accepts amounts at/above the minimum', () => {
25
+ expect(checkMinimum('transfer', MIN_MIST.transfer)).toBeNull()
26
+ expect(checkMinimum('swap', MIN_MIST.swap)).toBeNull()
27
+ expect(checkMinimum('supply', MIN_MIST.supply)).toBeNull()
28
+ expect(checkMinimum('borrow', MIN_MIST.borrow)).toBeNull()
29
+ })
30
+
31
+ test('the message names the amount, the minimum, and the action', () => {
32
+ const err = checkMinimum('swap', 5_000_000n) // 0.005 SUI
33
+ expect(err).toContain('0.005 SUI')
34
+ expect(err).toContain('0.01 SUI minimum for swap')
35
+ })
36
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Per-action minimum amounts (in MIST). Below these an on-chain call either
3
+ * hard-aborts (Sui native staking requires ≥ 1 SUI) or is economically
4
+ * pointless (dust deposits). We reject BEFORE building/simulating/executing and
5
+ * tell the user the minimum — so a too-small amount returns a clear
6
+ * "amount too small" error instead of a doomed transaction that could otherwise
7
+ * look like it succeeded.
8
+ */
9
+ export const MIN_MIST = {
10
+ transfer: 1_000_000n, // 0.001 SUI
11
+ swap: 10_000_000n, // 0.01 SUI
12
+ supply: 10_000_000n, // 0.01 SUI (lending deposit)
13
+ borrow: 10_000_000n, // 0.01 SUI
14
+ stake: 1_000_000_000n, // 1 SUI (Sui native staking hard minimum)
15
+ } as const
16
+
17
+ export type MinAction = keyof typeof MIN_MIST
18
+
19
+ const fmtSui = (mist: bigint): string => {
20
+ const s = (Number(mist) / 1e9).toString()
21
+ return s
22
+ }
23
+
24
+ /**
25
+ * Returns an error string when `amountMist` is below the action's minimum,
26
+ * otherwise null. Callers should early-return the string as `{ ok: false, error }`
27
+ * BEFORE executing anything.
28
+ */
29
+ export function checkMinimum(action: MinAction, amountMist: bigint): string | null {
30
+ const min = MIN_MIST[action]
31
+ if (amountMist < min) {
32
+ return `amount too small: ${fmtSui(amountMist)} SUI is below the ${fmtSui(min)} SUI minimum for ${action} — try at least ${fmtSui(min)} SUI`
33
+ }
34
+ return null
35
+ }
package/src/protocols.ts CHANGED
@@ -43,7 +43,7 @@ export const PROTOCOLS: ProtocolCapability[] = [
43
43
  execute: false,
44
44
  tools: ['deepbook.markets'],
45
45
  llamaProjects: ['deepbook'],
46
- note: 'market data read; on-chain order execution not wired yet',
46
+ note: 'market data read; swaps route through DeepBook via the 7k aggregator',
47
47
  },
48
48
  {
49
49
  id: 'scallop',
@@ -51,8 +51,16 @@ export const PROTOCOLS: ProtocolCapability[] = [
51
51
  category: 'lending',
52
52
  read: true,
53
53
  execute: true,
54
- tools: ['scallop.markets', 'scallop.position', 'scallop.supply', 'scallop.withdraw'],
54
+ tools: [
55
+ 'scallop.markets',
56
+ 'scallop.position',
57
+ 'scallop.supply',
58
+ 'scallop.withdraw',
59
+ 'scallop.borrow',
60
+ 'scallop.repay',
61
+ ],
55
62
  llamaProjects: ['scallop-lending', 'scallop'],
63
+ note: 'full supply / withdraw / borrow / repay',
56
64
  },
57
65
  {
58
66
  id: 'navi',
@@ -60,13 +68,55 @@ export const PROTOCOLS: ProtocolCapability[] = [
60
68
  category: 'lending',
61
69
  read: true,
62
70
  execute: true,
63
- tools: ['navi.markets', 'navi.position', 'navi.supply', 'navi.withdraw'],
71
+ tools: [
72
+ 'navi.markets',
73
+ 'navi.position',
74
+ 'navi.supply',
75
+ 'navi.withdraw',
76
+ 'navi.borrow',
77
+ 'navi.repay',
78
+ ],
64
79
  llamaProjects: ['navi-lending', 'navi-protocol', 'navi'],
65
- note: 'largest Sui lending market by TVL',
80
+ note: 'largest Sui lending market by TVL; full supply / withdraw / borrow / repay',
81
+ },
82
+ {
83
+ id: 'suilend',
84
+ name: 'Suilend',
85
+ category: 'lending',
86
+ read: true,
87
+ execute: true,
88
+ tools: [
89
+ 'suilend.position',
90
+ 'suilend.supply',
91
+ 'suilend.withdraw',
92
+ 'suilend.borrow',
93
+ 'suilend.repay',
94
+ ],
95
+ llamaProjects: ['suilend'],
96
+ note: 'MAIN_POOL market; full supply / withdraw / borrow / repay',
97
+ },
98
+ {
99
+ id: 'native-staking',
100
+ name: 'Sui Native Staking',
101
+ category: 'staking',
102
+ read: false,
103
+ execute: true,
104
+ tools: ['sui.stake', 'sui.unstake'],
105
+ note: 'delegate SUI to a validator (min 1 SUI); classic staking rewards',
106
+ },
107
+ {
108
+ id: 'volo',
109
+ name: 'Volo (Liquid Staking)',
110
+ category: 'staking',
111
+ read: false,
112
+ execute: true,
113
+ tools: ['volo.stake', 'volo.unstake'],
114
+ llamaProjects: ['volo', 'volo-staked-sui'],
115
+ note: 'stake SUI → vSUI, a liquid staking token usable across DeFi while earning',
66
116
  },
67
117
  {
68
118
  id: 'aggregator',
69
- name: 'DEX aggregator (7k / Cetus / FlowX / Bluefin)',
119
+ name: 'DEX aggregator (7k best-route)',
70
120
  category: 'aggregator',
71
121
  read: true,
72
122
  execute: true,
@@ -81,8 +131,12 @@ export const PROTOCOLS: ProtocolCapability[] = [
81
131
  'bluefin',
82
132
  'turbos',
83
133
  'kriya-dex',
134
+ 'aftermath-amm',
135
+ 'aftermath',
136
+ 'momentum',
137
+ 'mmt',
84
138
  ],
85
- note: 'swap executes via the 7k aggregator, routing across Cetus/FlowX/Bluefin/DeepBook',
139
+ note: 'swap executes via the 7k aggregator, routing best price across Cetus / Turbos / FlowX / Bluefin / Aftermath / Momentum / Kriya / DeepBook',
86
140
  },
87
141
  ]
88
142
 
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Volo liquid staking — stake SUI for vSUI (a liquid staking token that keeps
3
+ * earning while remaining tradeable / usable in DeFi), and unstake vSUI back to
4
+ * SUI. Built on navi-sdk's Volo PTB helpers.
5
+ *
6
+ * volo.stake → SUI → vSUI (stake_pool::stake)
7
+ * volo.unstake → vSUI → SUI (stake_pool::unstake)
8
+ *
9
+ * Same guarded pipeline: minimum guard → policy → dry-run simulate → execute →
10
+ * on-chain effects check.
11
+ */
12
+
13
+ import { Transaction, coinWithBalance } from '@mysten/sui/transactions'
14
+ import type { ToolDef } from 'lyra-core'
15
+ import { stakeTovSuiPTB, unstakeTovSui } from 'navi-sdk'
16
+ import { z } from 'zod'
17
+ import { checkMinimum } from '../minimums'
18
+ import { evaluatePolicy, suiToMist } from '../policy'
19
+ import { simulate } from '../simulate'
20
+ import type { OnchainRuntimeContext } from '../types'
21
+
22
+ const SUI_TYPE = '0x2::sui::SUI'
23
+ const VSUI_TYPE = '0x549e8b69270defbfafd4f94e17ec44cdbdd99820b33bda2278dea3b9a32d3f55::cert::CERT'
24
+
25
+ const StakeSchema = z.object({
26
+ amount: z.string().min(1).describe('Amount of SUI to liquid-stake (minimum 1 SUI).'),
27
+ })
28
+ type StakeArgs = z.infer<typeof StakeSchema>
29
+
30
+ const UnstakeSchema = z.object({
31
+ amount: z.string().min(1).describe('Amount of vSUI to unstake back to SUI.'),
32
+ })
33
+ type UnstakeArgs = z.infer<typeof UnstakeSchema>
34
+
35
+ export function makeVoloStake(ctx: OnchainRuntimeContext): ToolDef<StakeArgs> {
36
+ return {
37
+ name: 'volo.stake',
38
+ description:
39
+ 'Liquid-stake SUI with Volo and receive vSUI — a liquid staking token that keeps earning staking rewards while remaining tradeable and usable across Sui DeFi (unlike native staking, which locks the position). Minimum 1 SUI. Policy-checked, simulated, then executed.',
40
+ searchHint:
41
+ 'volo liquid stake vsui lst liquid-staking derivative earn yield tradeable sui defi',
42
+ schema: StakeSchema,
43
+ handler: async args => {
44
+ try {
45
+ if (ctx.network !== 'mainnet') return { ok: false, error: 'Volo supports mainnet only' }
46
+ const amountMist = suiToMist(args.amount)
47
+ if (amountMist === undefined || amountMist <= 0n) {
48
+ return { ok: false, error: `invalid amount "${args.amount}"` }
49
+ }
50
+ const tooSmall = checkMinimum('stake', amountMist)
51
+ if (tooSmall) return { ok: false, error: tooSmall }
52
+
53
+ if (ctx.policy) {
54
+ const verdict = evaluatePolicy(
55
+ { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'stake' },
56
+ ctx.policy,
57
+ )
58
+ if (!verdict.allowed) {
59
+ return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
60
+ }
61
+ }
62
+
63
+ const tx = new Transaction()
64
+ const [suiCoin] = tx.splitCoins(tx.gas, [amountMist])
65
+ const vsui = await stakeTovSuiPTB(tx as never, suiCoin as never)
66
+ tx.transferObjects([vsui as never], ctx.agentAddress)
67
+
68
+ const sim = await simulate(ctx.client, tx, ctx.agentAddress)
69
+ if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
70
+
71
+ const res = await ctx.client.signAndExecuteTransaction({
72
+ signer: ctx.keypair,
73
+ transaction: tx,
74
+ options: { showEffects: true },
75
+ })
76
+ if (res.effects?.status?.status !== 'success') {
77
+ return {
78
+ ok: false,
79
+ error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
80
+ }
81
+ }
82
+ await ctx.client.waitForTransaction({ digest: res.digest })
83
+ return {
84
+ ok: true,
85
+ data: {
86
+ protocol: 'volo',
87
+ action: 'liquid-stake',
88
+ amountSui: args.amount,
89
+ received: 'vSUI',
90
+ digest: res.digest,
91
+ status: 'success',
92
+ },
93
+ }
94
+ } catch (e) {
95
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
96
+ }
97
+ },
98
+ }
99
+ }
100
+
101
+ export function makeVoloUnstake(ctx: OnchainRuntimeContext): ToolDef<UnstakeArgs> {
102
+ return {
103
+ name: 'volo.unstake',
104
+ description:
105
+ 'Unstake vSUI back to SUI via Volo (returns SUI including accrued staking rewards). The amount is denominated in vSUI. Simulated, then executed.',
106
+ searchHint: 'volo unstake vsui redeem convert back sui liquid staking withdraw',
107
+ schema: UnstakeSchema,
108
+ handler: async args => {
109
+ try {
110
+ if (ctx.network !== 'mainnet') return { ok: false, error: 'Volo supports mainnet only' }
111
+ const amountMist = suiToMist(args.amount)
112
+ if (amountMist === undefined || amountMist <= 0n) {
113
+ return { ok: false, error: `invalid amount "${args.amount}"` }
114
+ }
115
+
116
+ const tx = new Transaction()
117
+ // coinWithBalance resolves the agent's vSUI coins into one of the exact size.
118
+ const vsuiCoin = coinWithBalance({ type: VSUI_TYPE, balance: amountMist })
119
+ const sui = await unstakeTovSui(tx as never, vsuiCoin as never)
120
+ tx.transferObjects([sui as never], ctx.agentAddress)
121
+
122
+ const sim = await simulate(ctx.client, tx, ctx.agentAddress)
123
+ if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
124
+
125
+ const res = await ctx.client.signAndExecuteTransaction({
126
+ signer: ctx.keypair,
127
+ transaction: tx,
128
+ options: { showEffects: true },
129
+ })
130
+ if (res.effects?.status?.status !== 'success') {
131
+ return {
132
+ ok: false,
133
+ error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
134
+ }
135
+ }
136
+ await ctx.client.waitForTransaction({ digest: res.digest })
137
+ return {
138
+ ok: true,
139
+ data: {
140
+ protocol: 'volo',
141
+ action: 'unstake',
142
+ amountVsui: args.amount,
143
+ digest: res.digest,
144
+ status: 'success',
145
+ },
146
+ }
147
+ } catch (e) {
148
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
149
+ }
150
+ },
151
+ }
152
+ }
package/src/tools/navi.ts CHANGED
@@ -10,8 +10,9 @@
10
10
 
11
11
  import { Transaction } from '@mysten/sui/transactions'
12
12
  import type { ToolDef } from 'lyra-core'
13
- import { NAVISDKClient, depositCoin, pool, withdrawCoin } from 'navi-sdk'
13
+ import { NAVISDKClient, borrowCoin, depositCoin, pool, repayDebt, withdrawCoin } from 'navi-sdk'
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'
@@ -107,17 +108,30 @@ type AmountArgs = z.infer<typeof AmountSchema>
107
108
  async function runNaviWrite(
108
109
  ctx: OnchainRuntimeContext,
109
110
  amount: string,
110
- kind: 'supply' | 'withdraw',
111
+ kind: 'supply' | 'withdraw' | 'borrow' | 'repay',
111
112
  ): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> {
112
113
  const err = ensureMainnet(ctx)
113
114
  if (err) return { ok: false, error: err }
114
115
  const amountMist = suiToMist(amount)
115
116
  if (amountMist === undefined || amountMist <= 0n)
116
117
  return { ok: false, error: `invalid amount "${amount}"` }
118
+ // Minimum guard for value-moving actions (withdraw brings value back in).
119
+ const minAction = kind === 'borrow' ? 'borrow' : kind === 'withdraw' ? null : 'supply'
120
+ if (minAction) {
121
+ const tooSmall = checkMinimum(minAction, amountMist)
122
+ if (tooSmall) return { ok: false, error: tooSmall }
123
+ }
117
124
 
118
- if (ctx.policy && kind === 'supply') {
125
+ // Policy gate on value-moving actions (borrow creates debt + hands out funds;
126
+ // repay/supply move SUI out). Withdraw pulls the agent's own funds back.
127
+ if (ctx.policy && kind !== 'withdraw') {
119
128
  const verdict = evaluatePolicy(
120
- { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'navi' },
129
+ {
130
+ kind: 'transfer',
131
+ coinType: SUI_TYPE,
132
+ amountMist,
133
+ protocol: kind === 'borrow' ? 'borrow' : 'navi',
134
+ },
121
135
  ctx.policy,
122
136
  )
123
137
  if (!verdict.allowed)
@@ -130,11 +144,19 @@ async function runNaviWrite(
130
144
  if (kind === 'supply') {
131
145
  const [coin] = tx.splitCoins(tx.gas, [amountMist])
132
146
  await depositCoin(tx as never, suiPool as never, coin as never, Number(amountMist))
133
- } else {
147
+ } else if (kind === 'withdraw') {
134
148
  // navi-sdk's withdrawCoin already wraps the withdrawn Balance into a Coin
135
149
  // (via coin::from_balance) and returns [coin]; destructure and transfer it.
136
150
  const [coin] = await withdrawCoin(tx as never, suiPool as never, Number(amountMist))
137
151
  tx.transferObjects([coin as never], ctx.agentAddress)
152
+ } else if (kind === 'borrow') {
153
+ // Borrow against supplied collateral; hand the borrowed coin to the agent.
154
+ const [coin] = await borrowCoin(tx as never, suiPool as never, Number(amountMist))
155
+ tx.transferObjects([coin as never], ctx.agentAddress)
156
+ } else {
157
+ // repay: split the repayment from gas and pay down the debt.
158
+ const [coin] = tx.splitCoins(tx.gas, [amountMist])
159
+ await repayDebt(tx as never, suiPool as never, coin as never, Number(amountMist))
138
160
  }
139
161
 
140
162
  const sim = await simulate(ctx.client, tx, ctx.agentAddress)
@@ -187,3 +209,24 @@ export function makeNaviWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountArgs
187
209
  handler: async args => runNaviWrite(ctx, args.amount, 'withdraw'),
188
210
  }
189
211
  }
212
+
213
+ export function makeNaviBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
214
+ return {
215
+ name: 'navi.borrow',
216
+ description:
217
+ 'Borrow SUI from NAVI against supplied collateral. Requires an existing supply position with enough health factor; the pre-flight simulation fails cleanly if under-collateralized. Policy-checked, simulated, then executed.',
218
+ searchHint: 'navi borrow loan leverage debt against collateral sui',
219
+ schema: AmountSchema,
220
+ handler: async args => runNaviWrite(ctx, args.amount, 'borrow'),
221
+ }
222
+ }
223
+
224
+ export function makeNaviRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
225
+ return {
226
+ name: 'navi.repay',
227
+ description: 'Repay borrowed SUI debt on NAVI. Simulated, then executed.',
228
+ searchHint: 'navi repay pay back debt loan close sui',
229
+ schema: AmountSchema,
230
+ handler: async args => runNaviWrite(ctx, args.amount, 'repay'),
231
+ }
232
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Integration coverage — LIVE mainnet dry-runs (devInspect, never broadcast).
3
+ *
4
+ * Gated: runs only when `LYRA_RUN_INTEGRATION=1` and a real `LYRA_AGENT_KEY` is
5
+ * present. In CI (no secret) the whole suite is skipped, so it never flakes the
6
+ * default `bun test`. Run locally with:
7
+ *
8
+ * LYRA_RUN_INTEGRATION=1 bun test src/tools/onchain.integration.test.ts
9
+ *
10
+ * Each write case builds the exact PTB its tool builds and simulates it against
11
+ * mainnet, proving the SDK integration + PTB are valid on-chain without moving
12
+ * funds. Read-only tools are invoked directly (safe).
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test'
16
+ import { Transaction } from '@mysten/sui/transactions'
17
+ import { LENDING_MARKET_ID, LENDING_MARKET_TYPE, SuilendClient } from '@suilend/sdk'
18
+ import { stakeTovSuiPTB } from 'navi-sdk'
19
+ import { keypairFromSecret, makeSuiClient } from '../client'
20
+ import type { OnchainRuntimeContext } from '../types'
21
+ import { makeScallopMarkets } from './scallop'
22
+ import { makeSuilendPosition } from './suilend'
23
+
24
+ const SUI = '0x2::sui::SUI'
25
+ const RUN = process.env.LYRA_RUN_INTEGRATION === '1' && !!process.env.LYRA_AGENT_KEY
26
+ const ONE_SUI = 1_000_000_000n
27
+
28
+ function realCtx(): OnchainRuntimeContext {
29
+ const client = makeSuiClient('mainnet')
30
+ const keypair = keypairFromSecret(process.env.LYRA_AGENT_KEY as string)
31
+ return {
32
+ client,
33
+ keypair,
34
+ agentAddress: keypair.getPublicKey().toSuiAddress(),
35
+ network: 'mainnet',
36
+ agentDir: '/tmp/lyra-integration',
37
+ }
38
+ }
39
+
40
+ async function simStatus(ctx: OnchainRuntimeContext, tx: Transaction): Promise<string | undefined> {
41
+ const r = await ctx.client.devInspectTransactionBlock({
42
+ sender: ctx.agentAddress,
43
+ transactionBlock: tx as never,
44
+ })
45
+ return r.effects?.status?.status
46
+ }
47
+
48
+ describe.skipIf(!RUN)('mainnet dry-run integration', () => {
49
+ test('native stake (1 SUI → validator) simulates success', async () => {
50
+ const ctx = realCtx()
51
+ const state = await ctx.client.getLatestSuiSystemState()
52
+ const validator = state.activeValidators
53
+ .slice()
54
+ .sort((a, b) => Number(b.votingPower) - Number(a.votingPower))[0]
55
+ if (!validator) throw new Error('no active validators')
56
+ const tx = new Transaction()
57
+ tx.setSender(ctx.agentAddress)
58
+ const [coin] = tx.splitCoins(tx.gas, [ONE_SUI])
59
+ tx.moveCall({
60
+ target: '0x3::sui_system::request_add_stake',
61
+ arguments: [tx.object('0x5'), coin, tx.pure.address(validator.suiAddress)],
62
+ })
63
+ expect(await simStatus(ctx, tx)).toBe('success')
64
+ }, 45_000)
65
+
66
+ test('Volo liquid stake (1 SUI → vSUI) simulates success', async () => {
67
+ const ctx = realCtx()
68
+ const tx = new Transaction()
69
+ tx.setSender(ctx.agentAddress)
70
+ const [coin] = tx.splitCoins(tx.gas, [ONE_SUI])
71
+ const vsui = await stakeTovSuiPTB(tx as never, coin as never)
72
+ tx.transferObjects([vsui as never], ctx.agentAddress)
73
+ expect(await simStatus(ctx, tx)).toBe('success')
74
+ }, 45_000)
75
+
76
+ test('Suilend supply (1 SUI) simulates success', async () => {
77
+ const ctx = realCtx()
78
+ const suilend = await SuilendClient.initialize(
79
+ LENDING_MARKET_ID,
80
+ LENDING_MARKET_TYPE,
81
+ ctx.client as never,
82
+ )
83
+ const caps = await SuilendClient.getObligationOwnerCaps(
84
+ ctx.agentAddress,
85
+ [LENDING_MARKET_TYPE],
86
+ ctx.client as never,
87
+ )
88
+ const tx = new Transaction()
89
+ tx.setSender(ctx.agentAddress)
90
+ const [coin] = tx.splitCoins(tx.gas, [ONE_SUI])
91
+ if (caps.length === 0) {
92
+ const cap = suilend.createObligation(tx as never)
93
+ suilend.deposit(coin as never, SUI, cap, tx as never)
94
+ tx.transferObjects([cap as never], ctx.agentAddress)
95
+ } else {
96
+ const c = caps[0] as { id: string | { id: string } }
97
+ const capId = typeof c.id === 'string' ? c.id : c.id.id
98
+ suilend.deposit(coin as never, SUI, capId, tx as never)
99
+ }
100
+ expect(await simStatus(ctx, tx)).toBe('success')
101
+ }, 45_000)
102
+
103
+ test('read-only tools return live data', async () => {
104
+ const ctx = realCtx()
105
+ const position = await makeSuilendPosition(ctx).handler({})
106
+ expect(position.ok).toBe(true)
107
+
108
+ const markets = await makeScallopMarkets(ctx).handler({ coins: 'sui' })
109
+ expect(markets.ok).toBe(true)
110
+ }, 45_000)
111
+ })