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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lyra-plugin-onchain",
|
|
3
|
-
"version": "0.1.
|
|
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 {
|
|
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
|
+
})
|
package/src/minimums.ts
ADDED
|
@@ -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;
|
|
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: [
|
|
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: [
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
+
})
|