lyra-plugin-onchain 0.1.7 → 0.1.9
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/client.ts +12 -3
- package/src/index.ts +16 -1
- package/src/protocols.ts +52 -5
- package/src/tools/liquid-stake.ts +152 -0
- package/src/tools/navi.ts +6 -1
- package/src/tools/onchain.integration.test.ts +111 -0
- package/src/tools/scallop.ts +41 -10
- package/src/tools/stake.ts +9 -3
- package/src/tools/suilend.ts +472 -0
- package/src/tools/tools.test.ts +140 -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.9",
|
|
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/client.ts
CHANGED
|
@@ -11,14 +11,23 @@ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
|
|
|
11
11
|
|
|
12
12
|
export type SuiNetwork = 'testnet' | 'mainnet'
|
|
13
13
|
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Fullnode JSON-RPC URL for a network. `LYRA_RPC_URL` overrides the default
|
|
16
|
+
* public endpoint — recommended in production: the public `fullnode.mainnet.
|
|
17
|
+
* sui.io` is load-balanced across replicas at different checkpoint heights, so
|
|
18
|
+
* a freshly-created object (e.g. a just-opened lending obligation) can flicker
|
|
19
|
+
* in and out between calls. A single dedicated node gives read-your-writes
|
|
20
|
+
* consistency. The override applies to mainnet only (testnet keeps its default).
|
|
21
|
+
*/
|
|
15
22
|
export function suiRpcUrl(network: SuiNetwork): string {
|
|
23
|
+
const override = process.env.LYRA_RPC_URL?.trim()
|
|
24
|
+
if (network === 'mainnet' && override) return override
|
|
16
25
|
return getFullnodeUrl(network)
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
/** A Sui JSON-RPC client for the given network. */
|
|
28
|
+
/** A Sui JSON-RPC client for the given network (honours `LYRA_RPC_URL`). */
|
|
20
29
|
export function makeSuiClient(network: SuiNetwork): SuiClient {
|
|
21
|
-
return new SuiClient({ url:
|
|
30
|
+
return new SuiClient({ url: suiRpcUrl(network) })
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
/**
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ 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 { makeVoloStake, makeVoloUnstake } from './tools/liquid-stake'
|
|
28
29
|
import {
|
|
29
30
|
makeNaviBorrow,
|
|
30
31
|
makeNaviMarkets,
|
|
@@ -43,6 +44,13 @@ import {
|
|
|
43
44
|
} from './tools/scallop'
|
|
44
45
|
import { makeSuiSend } from './tools/send'
|
|
45
46
|
import { makeStake, makeUnstake } from './tools/stake'
|
|
47
|
+
import {
|
|
48
|
+
makeSuilendBorrow,
|
|
49
|
+
makeSuilendPosition,
|
|
50
|
+
makeSuilendRepay,
|
|
51
|
+
makeSuilendSupply,
|
|
52
|
+
makeSuilendWithdraw,
|
|
53
|
+
} from './tools/suilend'
|
|
46
54
|
import { makeSwap } from './tools/swap'
|
|
47
55
|
import { makeWalrusStore } from './tools/walrus'
|
|
48
56
|
import type { OnchainRuntimeContext } from './types'
|
|
@@ -101,10 +109,17 @@ const plugin: NativePlugin = {
|
|
|
101
109
|
ctx.registerTool(makeNaviWithdraw(onchain) as ToolDef)
|
|
102
110
|
ctx.registerTool(makeNaviBorrow(onchain) as ToolDef)
|
|
103
111
|
ctx.registerTool(makeNaviRepay(onchain) as ToolDef)
|
|
112
|
+
ctx.registerTool(makeSuilendSupply(onchain) as ToolDef)
|
|
113
|
+
ctx.registerTool(makeSuilendWithdraw(onchain) as ToolDef)
|
|
114
|
+
ctx.registerTool(makeSuilendBorrow(onchain) as ToolDef)
|
|
115
|
+
ctx.registerTool(makeSuilendRepay(onchain) as ToolDef)
|
|
116
|
+
ctx.registerTool(makeSuilendPosition(onchain) as ToolDef)
|
|
104
117
|
|
|
105
|
-
//
|
|
118
|
+
// Staking: native delegation (min 1 SUI) + Volo liquid staking (vSUI).
|
|
106
119
|
ctx.registerTool(makeStake(onchain) as ToolDef)
|
|
107
120
|
ctx.registerTool(makeUnstake(onchain) as ToolDef)
|
|
121
|
+
ctx.registerTool(makeVoloStake(onchain) as ToolDef)
|
|
122
|
+
ctx.registerTool(makeVoloUnstake(onchain) as ToolDef)
|
|
108
123
|
},
|
|
109
124
|
}
|
|
110
125
|
|
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',
|
|
@@ -53,6 +53,7 @@ export const PROTOCOLS: ProtocolCapability[] = [
|
|
|
53
53
|
execute: true,
|
|
54
54
|
tools: ['scallop.markets', 'scallop.position', 'scallop.supply', 'scallop.withdraw'],
|
|
55
55
|
llamaProjects: ['scallop-lending', 'scallop'],
|
|
56
|
+
note: 'supply / withdraw lending yield (borrow/repay via NAVI or Suilend)',
|
|
56
57
|
},
|
|
57
58
|
{
|
|
58
59
|
id: 'navi',
|
|
@@ -60,13 +61,55 @@ export const PROTOCOLS: ProtocolCapability[] = [
|
|
|
60
61
|
category: 'lending',
|
|
61
62
|
read: true,
|
|
62
63
|
execute: true,
|
|
63
|
-
tools: [
|
|
64
|
+
tools: [
|
|
65
|
+
'navi.markets',
|
|
66
|
+
'navi.position',
|
|
67
|
+
'navi.supply',
|
|
68
|
+
'navi.withdraw',
|
|
69
|
+
'navi.borrow',
|
|
70
|
+
'navi.repay',
|
|
71
|
+
],
|
|
64
72
|
llamaProjects: ['navi-lending', 'navi-protocol', 'navi'],
|
|
65
|
-
note: 'largest Sui lending market by TVL',
|
|
73
|
+
note: 'largest Sui lending market by TVL; full supply / withdraw / borrow / repay',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'suilend',
|
|
77
|
+
name: 'Suilend',
|
|
78
|
+
category: 'lending',
|
|
79
|
+
read: true,
|
|
80
|
+
execute: true,
|
|
81
|
+
tools: [
|
|
82
|
+
'suilend.position',
|
|
83
|
+
'suilend.supply',
|
|
84
|
+
'suilend.withdraw',
|
|
85
|
+
'suilend.borrow',
|
|
86
|
+
'suilend.repay',
|
|
87
|
+
],
|
|
88
|
+
llamaProjects: ['suilend'],
|
|
89
|
+
note: 'MAIN_POOL market; full supply / withdraw / borrow / repay',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'native-staking',
|
|
93
|
+
name: 'Sui Native Staking',
|
|
94
|
+
category: 'staking',
|
|
95
|
+
read: false,
|
|
96
|
+
execute: true,
|
|
97
|
+
tools: ['sui.stake', 'sui.unstake'],
|
|
98
|
+
note: 'delegate SUI to a validator (min 1 SUI); classic staking rewards',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'volo',
|
|
102
|
+
name: 'Volo (Liquid Staking)',
|
|
103
|
+
category: 'staking',
|
|
104
|
+
read: false,
|
|
105
|
+
execute: true,
|
|
106
|
+
tools: ['volo.stake', 'volo.unstake'],
|
|
107
|
+
llamaProjects: ['volo', 'volo-staked-sui'],
|
|
108
|
+
note: 'stake SUI → vSUI, a liquid staking token usable across DeFi while earning',
|
|
66
109
|
},
|
|
67
110
|
{
|
|
68
111
|
id: 'aggregator',
|
|
69
|
-
name: 'DEX aggregator (7k
|
|
112
|
+
name: 'DEX aggregator (7k best-route)',
|
|
70
113
|
category: 'aggregator',
|
|
71
114
|
read: true,
|
|
72
115
|
execute: true,
|
|
@@ -81,8 +124,12 @@ export const PROTOCOLS: ProtocolCapability[] = [
|
|
|
81
124
|
'bluefin',
|
|
82
125
|
'turbos',
|
|
83
126
|
'kriya-dex',
|
|
127
|
+
'aftermath-amm',
|
|
128
|
+
'aftermath',
|
|
129
|
+
'momentum',
|
|
130
|
+
'mmt',
|
|
84
131
|
],
|
|
85
|
-
note: 'swap executes via the 7k aggregator, routing across Cetus/FlowX/Bluefin/DeepBook',
|
|
132
|
+
note: 'swap executes via the 7k aggregator, routing best price across Cetus / Turbos / FlowX / Bluefin / Aftermath / Momentum / Kriya / DeepBook',
|
|
86
133
|
},
|
|
87
134
|
]
|
|
88
135
|
|
|
@@ -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
|
@@ -126,7 +126,12 @@ async function runNaviWrite(
|
|
|
126
126
|
// repay/supply move SUI out). Withdraw pulls the agent's own funds back.
|
|
127
127
|
if (ctx.policy && kind !== 'withdraw') {
|
|
128
128
|
const verdict = evaluatePolicy(
|
|
129
|
-
{
|
|
129
|
+
{
|
|
130
|
+
kind: 'transfer',
|
|
131
|
+
coinType: SUI_TYPE,
|
|
132
|
+
amountMist,
|
|
133
|
+
protocol: kind === 'borrow' ? 'borrow' : 'navi',
|
|
134
|
+
},
|
|
130
135
|
ctx.policy,
|
|
131
136
|
)
|
|
132
137
|
if (!verdict.allowed)
|
|
@@ -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
|
+
})
|
package/src/tools/scallop.ts
CHANGED
|
@@ -129,6 +129,10 @@ const AmountSchema = z.object({
|
|
|
129
129
|
})
|
|
130
130
|
type AmountArgs = z.infer<typeof AmountSchema>
|
|
131
131
|
|
|
132
|
+
// Scallop redeems a MARKET-COIN amount on withdraw (not underlying SUI). We
|
|
133
|
+
// find the agent's SUI market coin by type pattern (robust to package upgrades).
|
|
134
|
+
const MARKET_COIN_SUI_RE = /::reserve::MarketCoin<0x0*2::sui::SUI>$/
|
|
135
|
+
|
|
132
136
|
async function runScallopWrite(
|
|
133
137
|
ctx: OnchainRuntimeContext,
|
|
134
138
|
amount: string,
|
|
@@ -139,12 +143,13 @@ async function runScallopWrite(
|
|
|
139
143
|
const amountMist = suiToMist(amount)
|
|
140
144
|
if (amountMist === undefined || amountMist <= 0n)
|
|
141
145
|
return { ok: false, error: `invalid amount "${amount}"` }
|
|
146
|
+
// Minimum guard on supply (withdraw pulls the agent's own funds back).
|
|
142
147
|
if (kind === 'supply') {
|
|
143
148
|
const tooSmall = checkMinimum('supply', amountMist)
|
|
144
149
|
if (tooSmall) return { ok: false, error: tooSmall }
|
|
145
150
|
}
|
|
146
151
|
|
|
147
|
-
// Policy gate
|
|
152
|
+
// Policy gate on the value-moving supply.
|
|
148
153
|
if (ctx.policy && kind === 'supply') {
|
|
149
154
|
const verdict = evaluatePolicy(
|
|
150
155
|
{ kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'scallop' },
|
|
@@ -159,15 +164,35 @@ async function runScallopWrite(
|
|
|
159
164
|
const builder = await sdk.createScallopBuilder()
|
|
160
165
|
const tx = builder.createTxBlock()
|
|
161
166
|
tx.setSender(ctx.agentAddress)
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
// depositQuick's 3rd arg returnSCoin=false keeps the old market-coin standard.
|
|
168
|
+
let out: unknown
|
|
169
|
+
if (kind === 'supply') {
|
|
170
|
+
out = await tx.depositQuick(Number(amountMist), 'sui', false)
|
|
171
|
+
} else {
|
|
172
|
+
// withdrawQuick redeems a MARKET-COIN amount, not underlying SUI. Convert
|
|
173
|
+
// the requested SUI to market coins at the position's rate and clamp to the
|
|
174
|
+
// held balance, so a full/over-withdraw cleanly redeems everything.
|
|
175
|
+
const [balances, q] = await Promise.all([
|
|
176
|
+
ctx.client.getAllBalances({ owner: ctx.agentAddress }),
|
|
177
|
+
sdk.createScallopQuery().then(async query => {
|
|
178
|
+
await query.init()
|
|
179
|
+
return query
|
|
180
|
+
}),
|
|
181
|
+
])
|
|
182
|
+
const mc = balances.find(b => MARKET_COIN_SUI_RE.test(b.coinType))
|
|
183
|
+
const heldMc = BigInt(mc?.totalBalance ?? '0')
|
|
184
|
+
if (heldMc <= 0n) return { ok: false, error: 'no Scallop SUI position to withdraw' }
|
|
185
|
+
const port = (await q.getUserPortfolio({ walletAddress: ctx.agentAddress })) as {
|
|
186
|
+
lendings?: Array<{ coinName?: string; suppliedCoin?: number }>
|
|
187
|
+
}
|
|
188
|
+
const supplied = (port.lendings ?? []).find(l => l.coinName === 'sui')?.suppliedCoin ?? 0
|
|
189
|
+
const suppliedMist = BigInt(Math.floor(supplied * 1e9))
|
|
190
|
+
// redeem = requested/rate, where rate = supplied/heldMc; clamp to heldMc.
|
|
191
|
+
let redeemMc = suppliedMist > 0n ? (amountMist * heldMc) / suppliedMist : heldMc
|
|
192
|
+
if (redeemMc > heldMc || redeemMc <= 0n) redeemMc = heldMc
|
|
193
|
+
out = await tx.withdrawQuick(Number(redeemMc), 'sui')
|
|
194
|
+
}
|
|
195
|
+
if (out) tx.transferObjects([out as never], ctx.agentAddress)
|
|
171
196
|
const transaction = tx.txBlock
|
|
172
197
|
|
|
173
198
|
const sim = await simulate(ctx.client, transaction, ctx.agentAddress)
|
|
@@ -220,3 +245,9 @@ export function makeScallopWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountA
|
|
|
220
245
|
handler: async args => runScallopWrite(ctx, args.amount, 'withdraw'),
|
|
221
246
|
}
|
|
222
247
|
}
|
|
248
|
+
|
|
249
|
+
// NOTE: Scallop borrow/repay require opening a Scallop obligation + posting
|
|
250
|
+
// collateral (a separate flow from lending deposits — borrowQuick alone aborts
|
|
251
|
+
// "No obligation found"). Until that flow is wired, borrowing routes through the
|
|
252
|
+
// verified NAVI/Suilend adapters instead, so we don't ship a borrow tool here
|
|
253
|
+
// that can't execute.
|
package/src/tools/stake.ts
CHANGED
|
@@ -31,7 +31,7 @@ async function resolveValidator(
|
|
|
31
31
|
// biome-ignore lint/suspicious/noExplicitAny: SuiSystemState validator fields
|
|
32
32
|
const vals = (state.activeValidators ?? []) as any[]
|
|
33
33
|
if (vals.length === 0) return null
|
|
34
|
-
if (want
|
|
34
|
+
if (want?.trim()) {
|
|
35
35
|
const w = want.trim().toLowerCase()
|
|
36
36
|
const m = vals.find(
|
|
37
37
|
v => String(v.suiAddress).toLowerCase() === w || String(v.name ?? '').toLowerCase() === w,
|
|
@@ -100,7 +100,10 @@ export function makeStake(ctx: OnchainRuntimeContext): ToolDef<StakeArgs> {
|
|
|
100
100
|
options: { showEffects: true, showObjectChanges: true },
|
|
101
101
|
})
|
|
102
102
|
if (res.effects?.status?.status !== 'success') {
|
|
103
|
-
return {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
106
|
+
}
|
|
104
107
|
}
|
|
105
108
|
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
106
109
|
const staked = res.objectChanges?.find(
|
|
@@ -181,7 +184,10 @@ export function makeUnstake(ctx: OnchainRuntimeContext): ToolDef<UnstakeArgs> {
|
|
|
181
184
|
options: { showEffects: true },
|
|
182
185
|
})
|
|
183
186
|
if (res.effects?.status?.status !== 'success') {
|
|
184
|
-
return {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
190
|
+
}
|
|
185
191
|
}
|
|
186
192
|
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
187
193
|
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suilend lending tools (supporting integration): SUI supply / withdraw /
|
|
3
|
+
* borrow / repay + a read-only position, on Suilend's MAIN_POOL lending market.
|
|
4
|
+
*
|
|
5
|
+
* Suilend is one of the three largest money markets on Sui. Its SDK's 3.x line
|
|
6
|
+
* pins @mysten/sui v2 (a Transaction-class ABI break vs. our v1 stack), so we
|
|
7
|
+
* use @suilend/sdk@1.1.x, whose @mysten/sui is a *peerDependency* — it binds to
|
|
8
|
+
* our hosted v1 copy, so the v1 Transaction we build is accepted directly by the
|
|
9
|
+
* SDK (verified end-to-end with a live mainnet dry-run of a deposit).
|
|
10
|
+
*
|
|
11
|
+
* Value semantics (checked against the SDK source):
|
|
12
|
+
* deposit / borrow / repay → underlying MIST (clean).
|
|
13
|
+
* withdraw → cTokens; we convert the requested underlying via
|
|
14
|
+
* the reserve's cToken exchange rate.
|
|
15
|
+
*
|
|
16
|
+
* Every write runs the same guarded pipeline as sui.send: minimum guard →
|
|
17
|
+
* policy gate → dry-run simulate → execute → on-chain effects check. The
|
|
18
|
+
* simulate is the safety net — an under-collateralized borrow or an over-sized
|
|
19
|
+
* withdraw fails before broadcast, never a false success.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { Transaction } from '@mysten/sui/transactions'
|
|
23
|
+
import {
|
|
24
|
+
LENDING_MARKET_ID,
|
|
25
|
+
LENDING_MARKET_TYPE,
|
|
26
|
+
SuilendClient,
|
|
27
|
+
initializeSuilend,
|
|
28
|
+
} from '@suilend/sdk'
|
|
29
|
+
import type { ToolDef } from 'lyra-core'
|
|
30
|
+
import { z } from 'zod'
|
|
31
|
+
import { checkMinimum } from '../minimums'
|
|
32
|
+
import { evaluatePolicy, suiToMist } from '../policy'
|
|
33
|
+
import { simulate } from '../simulate'
|
|
34
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
35
|
+
|
|
36
|
+
const SUI_TYPE = '0x2::sui::SUI'
|
|
37
|
+
// Canonical + long-form SUI coin types (reserve maps may use either).
|
|
38
|
+
const SUI_LONG = `0x${'0'.repeat(63)}2::sui::SUI`
|
|
39
|
+
|
|
40
|
+
function ensureMainnet(ctx: OnchainRuntimeContext): string | null {
|
|
41
|
+
return ctx.network === 'mainnet' ? null : 'Suilend SDK supports mainnet only'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function newSuilend(ctx: OnchainRuntimeContext): Promise<SuilendClient> {
|
|
45
|
+
// ctx.client is @mysten/sui v1.4x; @suilend/sdk@1.1.x binds @mysten/sui as a
|
|
46
|
+
// peer, so the client interops directly across the copy boundary.
|
|
47
|
+
return SuilendClient.initialize(LENDING_MARKET_ID, LENDING_MARKET_TYPE, ctx.client as never)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Extract the object id from a parsed ObligationOwnerCap (`id` is a UID). */
|
|
51
|
+
function capObjectId(cap: { id: unknown }): string {
|
|
52
|
+
const id = cap.id as string | { id: string }
|
|
53
|
+
return typeof id === 'string' ? id : id.id
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isSuiType(t: string): boolean {
|
|
57
|
+
return t === SUI_TYPE || t === SUI_LONG
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The agent's obligation cap + id, or null if it has no Suilend position yet.
|
|
61
|
+
* Retries: getObligationOwnerCaps reads owned objects + their BCS, which some
|
|
62
|
+
* fullnode replicas serve inconsistently right after a write ("invalid data
|
|
63
|
+
* type" / stale owned-object index) — a short retry rides out that lag. */
|
|
64
|
+
async function findObligation(
|
|
65
|
+
ctx: OnchainRuntimeContext,
|
|
66
|
+
): Promise<{ capId: string; obligationId: string } | null> {
|
|
67
|
+
let lastErr: unknown
|
|
68
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
69
|
+
try {
|
|
70
|
+
const caps = await SuilendClient.getObligationOwnerCaps(
|
|
71
|
+
ctx.agentAddress,
|
|
72
|
+
[LENDING_MARKET_TYPE],
|
|
73
|
+
ctx.client as never,
|
|
74
|
+
)
|
|
75
|
+
if (!caps.length) return null
|
|
76
|
+
const c = caps[0] as { id: unknown; obligationId: string | { id: string } }
|
|
77
|
+
const obligationId = typeof c.obligationId === 'string' ? c.obligationId : c.obligationId.id
|
|
78
|
+
return { capId: capObjectId(c), obligationId }
|
|
79
|
+
} catch (e) {
|
|
80
|
+
lastErr = e
|
|
81
|
+
await new Promise(r => setTimeout(r, 1200))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw lastErr
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- suilend.supply --------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
const AmountSchema = z.object({
|
|
90
|
+
amount: z.string().min(1).describe('Amount of SUI, e.g. "1.5".'),
|
|
91
|
+
})
|
|
92
|
+
type AmountArgs = z.infer<typeof AmountSchema>
|
|
93
|
+
|
|
94
|
+
// Borrowable/repayable assets. Suilend (like most money markets) forbids
|
|
95
|
+
// borrowing the SAME asset you post as collateral (obligation::borrow abort 8),
|
|
96
|
+
// so the canonical flow is "supply SUI → borrow a stablecoin". USDC is the
|
|
97
|
+
// default; SUI is available for the reverse (supply a stable, borrow SUI).
|
|
98
|
+
const USDC_TYPE = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
|
|
99
|
+
type CoinInfo = { type: string; decimals: number; minBase: bigint; label: string }
|
|
100
|
+
const BORROW_COINS: Record<'usdc' | 'sui', CoinInfo> = {
|
|
101
|
+
usdc: { type: USDC_TYPE, decimals: 6, minBase: 100_000n, label: 'USDC' }, // 0.1 USDC
|
|
102
|
+
sui: { type: SUI_TYPE, decimals: 9, minBase: 10_000_000n, label: 'SUI' }, // 0.01 SUI
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Parse a decimal amount into base units for a given decimals count. */
|
|
106
|
+
function toBaseUnits(amount: string, decimals: number): bigint | undefined {
|
|
107
|
+
const a = amount.trim()
|
|
108
|
+
if (!/^\d+(\.\d+)?$/.test(a)) return undefined
|
|
109
|
+
const dot = a.indexOf('.')
|
|
110
|
+
const whole = dot === -1 ? a : a.slice(0, dot)
|
|
111
|
+
const frac = dot === -1 ? '' : a.slice(dot + 1)
|
|
112
|
+
const fracPadded = (frac + '0'.repeat(decimals)).slice(0, decimals)
|
|
113
|
+
try {
|
|
114
|
+
return BigInt(whole || '0') * 10n ** BigInt(decimals) + BigInt(fracPadded || '0')
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const BorrowSchema = z.object({
|
|
121
|
+
amount: z.string().min(1).describe('Amount to borrow/repay, e.g. "5".'),
|
|
122
|
+
coin: z
|
|
123
|
+
.enum(['usdc', 'sui'])
|
|
124
|
+
.optional()
|
|
125
|
+
.describe(
|
|
126
|
+
'Asset to borrow/repay (default "usdc"). Suilend disallows borrowing the same asset you supply as collateral, so borrowing USDC against SUI collateral is the canonical flow.',
|
|
127
|
+
),
|
|
128
|
+
})
|
|
129
|
+
type BorrowArgs = z.infer<typeof BorrowSchema>
|
|
130
|
+
|
|
131
|
+
export function makeSuilendSupply(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
132
|
+
return {
|
|
133
|
+
name: 'suilend.supply',
|
|
134
|
+
description:
|
|
135
|
+
'Supply (deposit) idle SUI into Suilend to earn lending yield. Creates a Suilend obligation on first use, then deposits into it. Policy-checked, simulated, then executed.',
|
|
136
|
+
searchHint: 'suilend supply deposit lend sui earn yield idle money market',
|
|
137
|
+
schema: AmountSchema,
|
|
138
|
+
handler: async args => {
|
|
139
|
+
const err = ensureMainnet(ctx)
|
|
140
|
+
if (err) return { ok: false, error: err }
|
|
141
|
+
const amountMist = suiToMist(args.amount)
|
|
142
|
+
if (amountMist === undefined || amountMist <= 0n)
|
|
143
|
+
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
144
|
+
const tooSmall = checkMinimum('supply', amountMist)
|
|
145
|
+
if (tooSmall) return { ok: false, error: tooSmall }
|
|
146
|
+
if (ctx.policy) {
|
|
147
|
+
const verdict = evaluatePolicy(
|
|
148
|
+
{ kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'suilend' },
|
|
149
|
+
ctx.policy,
|
|
150
|
+
)
|
|
151
|
+
if (!verdict.allowed)
|
|
152
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const suilend = await newSuilend(ctx)
|
|
156
|
+
const existing = await findObligation(ctx)
|
|
157
|
+
const tx = new Transaction()
|
|
158
|
+
tx.setSender(ctx.agentAddress)
|
|
159
|
+
const [coin] = tx.splitCoins(tx.gas, [amountMist])
|
|
160
|
+
// The @suilend/sdk@1.1.x SDK carries its own nested @mysten/sui copy, so
|
|
161
|
+
// TS sees a distinct Transaction class — cast at the boundary. The two
|
|
162
|
+
// copies interop at runtime (verified with a live mainnet dry-run).
|
|
163
|
+
if (existing) {
|
|
164
|
+
suilend.deposit(coin as never, SUI_TYPE, existing.capId, tx as never)
|
|
165
|
+
} else {
|
|
166
|
+
const cap = suilend.createObligation(tx as never)
|
|
167
|
+
suilend.deposit(coin as never, SUI_TYPE, cap, tx as never)
|
|
168
|
+
tx.transferObjects([cap as never], ctx.agentAddress)
|
|
169
|
+
}
|
|
170
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
171
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
172
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
173
|
+
signer: ctx.keypair,
|
|
174
|
+
transaction: tx,
|
|
175
|
+
options: { showEffects: true },
|
|
176
|
+
})
|
|
177
|
+
if (res.effects?.status?.status !== 'success')
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
181
|
+
}
|
|
182
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
183
|
+
return {
|
|
184
|
+
ok: true,
|
|
185
|
+
data: {
|
|
186
|
+
protocol: 'suilend',
|
|
187
|
+
action: 'supply',
|
|
188
|
+
amountSui: args.amount,
|
|
189
|
+
newObligation: !existing,
|
|
190
|
+
digest: res.digest,
|
|
191
|
+
policyEnforced: ctx.policy != null,
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- suilend.withdraw ------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
export function makeSuilendWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
204
|
+
return {
|
|
205
|
+
name: 'suilend.withdraw',
|
|
206
|
+
description:
|
|
207
|
+
'Withdraw supplied SUI from Suilend back to the agent (amount in underlying SUI). Refreshes prices, converts to cTokens at the current exchange rate, simulated, then executed.',
|
|
208
|
+
searchHint: 'suilend withdraw redeem unlend remove supplied sui money market',
|
|
209
|
+
schema: AmountSchema,
|
|
210
|
+
handler: async args => {
|
|
211
|
+
const err = ensureMainnet(ctx)
|
|
212
|
+
if (err) return { ok: false, error: err }
|
|
213
|
+
const amountMist = suiToMist(args.amount)
|
|
214
|
+
if (amountMist === undefined || amountMist <= 0n)
|
|
215
|
+
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
216
|
+
try {
|
|
217
|
+
const suilend = await newSuilend(ctx)
|
|
218
|
+
const obligation = await findObligation(ctx)
|
|
219
|
+
if (!obligation)
|
|
220
|
+
return { ok: false, error: 'no Suilend position — supply SUI before withdrawing' }
|
|
221
|
+
// Convert underlying → cTokens using the SUI reserve exchange rate.
|
|
222
|
+
const data = await initializeSuilend(ctx.client as never, suilend)
|
|
223
|
+
const rate = suiReserveExchangeRate(data)
|
|
224
|
+
if (rate === null) return { ok: false, error: 'could not read SUI reserve exchange rate' }
|
|
225
|
+
// cTokens = floor(underlying / rate); floor keeps us at/under the request.
|
|
226
|
+
const ctokens = BigInt(Math.floor(Number(amountMist) / rate))
|
|
227
|
+
if (ctokens <= 0n) return { ok: false, error: 'amount too small to withdraw' }
|
|
228
|
+
const tx = new Transaction()
|
|
229
|
+
tx.setSender(ctx.agentAddress)
|
|
230
|
+
await suilend.withdrawAndSendToUser(
|
|
231
|
+
ctx.agentAddress,
|
|
232
|
+
obligation.capId,
|
|
233
|
+
obligation.obligationId,
|
|
234
|
+
SUI_TYPE,
|
|
235
|
+
ctokens.toString(),
|
|
236
|
+
tx as never,
|
|
237
|
+
)
|
|
238
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
239
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
240
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
241
|
+
signer: ctx.keypair,
|
|
242
|
+
transaction: tx,
|
|
243
|
+
options: { showEffects: true },
|
|
244
|
+
})
|
|
245
|
+
if (res.effects?.status?.status !== 'success')
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
249
|
+
}
|
|
250
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
251
|
+
return {
|
|
252
|
+
ok: true,
|
|
253
|
+
data: {
|
|
254
|
+
protocol: 'suilend',
|
|
255
|
+
action: 'withdraw',
|
|
256
|
+
amountSui: args.amount,
|
|
257
|
+
digest: res.digest,
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
} catch (e) {
|
|
261
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- suilend.borrow --------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<BorrowArgs> {
|
|
270
|
+
return {
|
|
271
|
+
name: 'suilend.borrow',
|
|
272
|
+
description:
|
|
273
|
+
'Borrow an asset from Suilend against supplied collateral. Default borrows USDC against SUI collateral (Suilend disallows same-asset borrows). Requires an existing obligation with enough health; the pre-flight simulation fails cleanly if under-collateralized. Policy-checked, simulated, then executed.',
|
|
274
|
+
searchHint: 'suilend borrow loan leverage debt against collateral usdc stablecoin money market',
|
|
275
|
+
schema: BorrowSchema,
|
|
276
|
+
handler: async args => {
|
|
277
|
+
const err = ensureMainnet(ctx)
|
|
278
|
+
if (err) return { ok: false, error: err }
|
|
279
|
+
const coin = BORROW_COINS[args.coin ?? 'usdc']
|
|
280
|
+
const amountBase = toBaseUnits(args.amount, coin.decimals)
|
|
281
|
+
if (amountBase === undefined || amountBase <= 0n)
|
|
282
|
+
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
283
|
+
if (amountBase < coin.minBase)
|
|
284
|
+
return { ok: false, error: `amount too small: below the minimum ${coin.label} borrow` }
|
|
285
|
+
if (ctx.policy) {
|
|
286
|
+
const verdict = evaluatePolicy(
|
|
287
|
+
{ kind: 'transfer', coinType: coin.type, amountMist: amountBase, protocol: 'borrow' },
|
|
288
|
+
ctx.policy,
|
|
289
|
+
)
|
|
290
|
+
if (!verdict.allowed)
|
|
291
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const suilend = await newSuilend(ctx)
|
|
295
|
+
const obligation = await findObligation(ctx)
|
|
296
|
+
if (!obligation)
|
|
297
|
+
return { ok: false, error: 'no Suilend position — supply collateral before borrowing' }
|
|
298
|
+
const tx = new Transaction()
|
|
299
|
+
tx.setSender(ctx.agentAddress)
|
|
300
|
+
// borrow() self-refreshes prices (addRefreshCalls default true).
|
|
301
|
+
await suilend.borrowAndSendToUser(
|
|
302
|
+
ctx.agentAddress,
|
|
303
|
+
obligation.capId,
|
|
304
|
+
obligation.obligationId,
|
|
305
|
+
coin.type,
|
|
306
|
+
amountBase.toString(),
|
|
307
|
+
tx as never,
|
|
308
|
+
)
|
|
309
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
310
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
311
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
312
|
+
signer: ctx.keypair,
|
|
313
|
+
transaction: tx,
|
|
314
|
+
options: { showEffects: true },
|
|
315
|
+
})
|
|
316
|
+
if (res.effects?.status?.status !== 'success')
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
320
|
+
}
|
|
321
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
322
|
+
return {
|
|
323
|
+
ok: true,
|
|
324
|
+
data: {
|
|
325
|
+
protocol: 'suilend',
|
|
326
|
+
action: 'borrow',
|
|
327
|
+
amount: args.amount,
|
|
328
|
+
coin: coin.label,
|
|
329
|
+
digest: res.digest,
|
|
330
|
+
policyEnforced: ctx.policy != null,
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
} catch (e) {
|
|
334
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// --- suilend.repay ---------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<BorrowArgs> {
|
|
343
|
+
return {
|
|
344
|
+
name: 'suilend.repay',
|
|
345
|
+
description:
|
|
346
|
+
'Repay borrowed debt on Suilend (default USDC; pass coin "sui" to repay a SUI loan). Simulated, then executed.',
|
|
347
|
+
searchHint: 'suilend repay pay back debt loan close usdc stablecoin sui money market',
|
|
348
|
+
schema: BorrowSchema,
|
|
349
|
+
handler: async args => {
|
|
350
|
+
const err = ensureMainnet(ctx)
|
|
351
|
+
if (err) return { ok: false, error: err }
|
|
352
|
+
const coin = BORROW_COINS[args.coin ?? 'usdc']
|
|
353
|
+
const amountBase = toBaseUnits(args.amount, coin.decimals)
|
|
354
|
+
if (amountBase === undefined || amountBase <= 0n)
|
|
355
|
+
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
356
|
+
if (ctx.policy) {
|
|
357
|
+
const verdict = evaluatePolicy(
|
|
358
|
+
{ kind: 'transfer', coinType: coin.type, amountMist: amountBase, protocol: 'suilend' },
|
|
359
|
+
ctx.policy,
|
|
360
|
+
)
|
|
361
|
+
if (!verdict.allowed)
|
|
362
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const suilend = await newSuilend(ctx)
|
|
366
|
+
const obligation = await findObligation(ctx)
|
|
367
|
+
if (!obligation) return { ok: false, error: 'no Suilend position — nothing to repay' }
|
|
368
|
+
const tx = new Transaction()
|
|
369
|
+
tx.setSender(ctx.agentAddress)
|
|
370
|
+
await suilend.repayIntoObligation(
|
|
371
|
+
ctx.agentAddress,
|
|
372
|
+
obligation.obligationId,
|
|
373
|
+
coin.type,
|
|
374
|
+
amountBase.toString(),
|
|
375
|
+
tx as never,
|
|
376
|
+
)
|
|
377
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
378
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
379
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
380
|
+
signer: ctx.keypair,
|
|
381
|
+
transaction: tx,
|
|
382
|
+
options: { showEffects: true },
|
|
383
|
+
})
|
|
384
|
+
if (res.effects?.status?.status !== 'success')
|
|
385
|
+
return {
|
|
386
|
+
ok: false,
|
|
387
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
388
|
+
}
|
|
389
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
390
|
+
return {
|
|
391
|
+
ok: true,
|
|
392
|
+
data: {
|
|
393
|
+
protocol: 'suilend',
|
|
394
|
+
action: 'repay',
|
|
395
|
+
amount: args.amount,
|
|
396
|
+
coin: coin.label,
|
|
397
|
+
digest: res.digest,
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
} catch (e) {
|
|
401
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// --- suilend.position (read) -----------------------------------------------
|
|
408
|
+
|
|
409
|
+
const PositionSchema = z.object({})
|
|
410
|
+
type PositionArgs = z.infer<typeof PositionSchema>
|
|
411
|
+
|
|
412
|
+
export function makeSuilendPosition(ctx: OnchainRuntimeContext): ToolDef<PositionArgs> {
|
|
413
|
+
return {
|
|
414
|
+
name: 'suilend.position',
|
|
415
|
+
description:
|
|
416
|
+
"The agent's Suilend position: deposits, borrows, and per-asset balances. Read-only.",
|
|
417
|
+
searchHint: 'suilend position portfolio deposits borrows collateral debt health',
|
|
418
|
+
schema: PositionSchema,
|
|
419
|
+
handler: async () => {
|
|
420
|
+
const err = ensureMainnet(ctx)
|
|
421
|
+
if (err) return { ok: false, error: err }
|
|
422
|
+
try {
|
|
423
|
+
const suilend = await newSuilend(ctx)
|
|
424
|
+
const obligation = await findObligation(ctx)
|
|
425
|
+
if (!obligation)
|
|
426
|
+
return {
|
|
427
|
+
ok: true,
|
|
428
|
+
data: { protocol: 'suilend', hasPosition: false, deposits: [], borrows: [] },
|
|
429
|
+
}
|
|
430
|
+
const parsed = (await suilend.getObligation(obligation.obligationId)) as {
|
|
431
|
+
deposits?: Array<{ coinType?: { name?: string }; depositedCtokenAmount?: bigint }>
|
|
432
|
+
borrows?: Array<{ coinType?: { name?: string }; borrowedAmount?: { value?: bigint } }>
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
ok: true,
|
|
436
|
+
data: {
|
|
437
|
+
protocol: 'suilend',
|
|
438
|
+
hasPosition: true,
|
|
439
|
+
obligationId: obligation.obligationId,
|
|
440
|
+
deposits: (parsed.deposits ?? []).map(d => ({
|
|
441
|
+
coin: d.coinType?.name,
|
|
442
|
+
ctokens: String(d.depositedCtokenAmount ?? 0n),
|
|
443
|
+
})),
|
|
444
|
+
borrows: (parsed.borrows ?? []).map(b => ({
|
|
445
|
+
coin: b.coinType?.name,
|
|
446
|
+
borrowed: String(b.borrowedAmount?.value ?? 0n),
|
|
447
|
+
})),
|
|
448
|
+
},
|
|
449
|
+
}
|
|
450
|
+
} catch (e) {
|
|
451
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Find the SUI reserve's cToken exchange rate (underlying per cToken) from an
|
|
458
|
+
* initializeSuilend() result, or null if not present. */
|
|
459
|
+
function suiReserveExchangeRate(data: unknown): number | null {
|
|
460
|
+
const d = data as {
|
|
461
|
+
reserveMap?: Record<string, { coinType?: string; cTokenExchangeRate?: unknown }>
|
|
462
|
+
}
|
|
463
|
+
const map = d.reserveMap
|
|
464
|
+
if (!map) return null
|
|
465
|
+
const reserve =
|
|
466
|
+
map[SUI_TYPE] ??
|
|
467
|
+
map[SUI_LONG] ??
|
|
468
|
+
Object.values(map).find(r => typeof r.coinType === 'string' && isSuiType(r.coinType))
|
|
469
|
+
if (!reserve) return null
|
|
470
|
+
const rate = Number(String(reserve.cTokenExchangeRate))
|
|
471
|
+
return Number.isFinite(rate) && rate > 0 ? rate : null
|
|
472
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit coverage for the DeFi tool surface: (1) the plugin registers every
|
|
3
|
+
* value-moving adapter, and (2) each new write tool runs its guard checks
|
|
4
|
+
* (mainnet-only, valid amount, minimum size) BEFORE it ever touches the
|
|
5
|
+
* network — so a dust/invalid/testnet request fails fast and deterministically,
|
|
6
|
+
* never a false success.
|
|
7
|
+
*
|
|
8
|
+
* These tests deliberately use a bare stub context: the guard branches return
|
|
9
|
+
* before any `client`/`keypair` access, so no RPC is needed. Live end-to-end
|
|
10
|
+
* PTB simulation lives in `onchain.integration.test.ts` (gated on a real key).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test'
|
|
14
|
+
import type { PluginContext, ToolDef } from 'lyra-core'
|
|
15
|
+
import plugin from '../index'
|
|
16
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
17
|
+
import { makeVoloStake } from './liquid-stake'
|
|
18
|
+
import { makeNaviBorrow, makeNaviRepay } from './navi'
|
|
19
|
+
import { makeSuilendBorrow, makeSuilendSupply } from './suilend'
|
|
20
|
+
|
|
21
|
+
// A context whose network/amount guards resolve before any client access.
|
|
22
|
+
function stubCtx(network: 'mainnet' | 'testnet' = 'mainnet'): OnchainRuntimeContext {
|
|
23
|
+
return {
|
|
24
|
+
network,
|
|
25
|
+
agentAddress: '0x0000000000000000000000000000000000000000000000000000000000000abc',
|
|
26
|
+
agentDir: '/tmp/lyra-test',
|
|
27
|
+
// Accessing either of these would throw — guards must return first.
|
|
28
|
+
client: new Proxy(
|
|
29
|
+
{},
|
|
30
|
+
{
|
|
31
|
+
get() {
|
|
32
|
+
throw new Error('network access before guard')
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
) as never,
|
|
36
|
+
keypair: {} as never,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('onchain plugin registration', () => {
|
|
41
|
+
test('registers the full DeFi write surface (lending + staking + swap)', () => {
|
|
42
|
+
const names: string[] = []
|
|
43
|
+
const ctx = {
|
|
44
|
+
registerTool: (def: ToolDef) => names.push(def.name),
|
|
45
|
+
registerListener: () => {},
|
|
46
|
+
addHook: () => {},
|
|
47
|
+
network: 'mainnet',
|
|
48
|
+
agentDir: '/tmp',
|
|
49
|
+
agentId: 'test',
|
|
50
|
+
// side-banded onchain runtime the plugin reads off PluginContext
|
|
51
|
+
onchain: stubCtx(),
|
|
52
|
+
} as unknown as PluginContext
|
|
53
|
+
plugin.register(ctx)
|
|
54
|
+
|
|
55
|
+
for (const expected of [
|
|
56
|
+
// lending: the three biggest Sui money markets, full CRUD
|
|
57
|
+
'scallop.supply',
|
|
58
|
+
'scallop.withdraw',
|
|
59
|
+
'navi.supply',
|
|
60
|
+
'navi.borrow',
|
|
61
|
+
'navi.repay',
|
|
62
|
+
'suilend.supply',
|
|
63
|
+
'suilend.withdraw',
|
|
64
|
+
'suilend.borrow',
|
|
65
|
+
'suilend.repay',
|
|
66
|
+
'suilend.position',
|
|
67
|
+
// staking: native + Volo liquid staking
|
|
68
|
+
'sui.stake',
|
|
69
|
+
'sui.unstake',
|
|
70
|
+
'volo.stake',
|
|
71
|
+
'volo.unstake',
|
|
72
|
+
// swap aggregator
|
|
73
|
+
'swap',
|
|
74
|
+
]) {
|
|
75
|
+
expect(names).toContain(expected)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('registers nothing without an onchain runtime (safe no-op for loaders)', () => {
|
|
80
|
+
const names: string[] = []
|
|
81
|
+
const ctx = {
|
|
82
|
+
registerTool: (def: ToolDef) => names.push(def.name),
|
|
83
|
+
registerListener: () => {},
|
|
84
|
+
addHook: () => {},
|
|
85
|
+
network: 'mainnet',
|
|
86
|
+
agentDir: '/tmp',
|
|
87
|
+
agentId: 'test',
|
|
88
|
+
} as unknown as PluginContext
|
|
89
|
+
plugin.register(ctx)
|
|
90
|
+
expect(names).toHaveLength(0)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('write-tool guards (fail before network)', () => {
|
|
95
|
+
test('suilend.supply rejects a dust amount below the supply minimum', async () => {
|
|
96
|
+
const res = await makeSuilendSupply(stubCtx()).handler({ amount: '0.001' })
|
|
97
|
+
expect(res.ok).toBe(false)
|
|
98
|
+
if (!res.ok) expect(res.error).toContain('amount too small')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('suilend.borrow rejects a dust amount below the borrow minimum', async () => {
|
|
102
|
+
const res = await makeSuilendBorrow(stubCtx()).handler({ amount: '0.0001' })
|
|
103
|
+
expect(res.ok).toBe(false)
|
|
104
|
+
if (!res.ok) expect(res.error).toContain('amount too small')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('volo.stake rejects below the 1 SUI staking minimum', async () => {
|
|
108
|
+
const res = await makeVoloStake(stubCtx()).handler({ amount: '0.1' })
|
|
109
|
+
expect(res.ok).toBe(false)
|
|
110
|
+
if (!res.ok) expect(res.error).toContain('amount too small')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('navi.borrow rejects a dust amount', async () => {
|
|
114
|
+
const res = await makeNaviBorrow(stubCtx()).handler({ amount: '0.001' })
|
|
115
|
+
expect(res.ok).toBe(false)
|
|
116
|
+
if (!res.ok) expect(res.error).toContain('amount too small')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('invalid amount strings are rejected before any network call', async () => {
|
|
120
|
+
const res = await makeVoloStake(stubCtx()).handler({ amount: 'not-a-number' })
|
|
121
|
+
expect(res.ok).toBe(false)
|
|
122
|
+
if (!res.ok) expect(res.error).toContain('invalid amount')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('mainnet-only tools refuse to run on testnet', async () => {
|
|
126
|
+
const supply = await makeSuilendSupply(stubCtx('testnet')).handler({ amount: '5' })
|
|
127
|
+
expect(supply.ok).toBe(false)
|
|
128
|
+
if (!supply.ok) expect(supply.error).toContain('mainnet only')
|
|
129
|
+
|
|
130
|
+
const stake = await makeVoloStake(stubCtx('testnet')).handler({ amount: '5' })
|
|
131
|
+
expect(stake.ok).toBe(false)
|
|
132
|
+
if (!stake.ok) expect(stake.error).toContain('mainnet only')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('navi.repay is a registered, well-formed tool', () => {
|
|
136
|
+
const tool = makeNaviRepay(stubCtx())
|
|
137
|
+
expect(tool.name).toBe('navi.repay')
|
|
138
|
+
expect(typeof tool.handler).toBe('function')
|
|
139
|
+
})
|
|
140
|
+
})
|