lyra-plugin-onchain 0.1.8 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lyra-plugin-onchain",
3
- "version": "0.1.8",
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",
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
- /** Canonical fullnode JSON-RPC URL for a network. */
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: getFullnodeUrl(network) })
30
+ return new SuiClient({ url: suiRpcUrl(network) })
22
31
  }
23
32
 
24
33
  /**
package/src/index.ts CHANGED
@@ -37,10 +37,8 @@ import {
37
37
  import { makePolicyCreate, makePolicyShow } from './tools/policy'
38
38
  import { makeProtocolsList } from './tools/protocols'
39
39
  import {
40
- makeScallopBorrow,
41
40
  makeScallopMarkets,
42
41
  makeScallopPosition,
43
- makeScallopRepay,
44
42
  makeScallopSupply,
45
43
  makeScallopWithdraw,
46
44
  } from './tools/scallop'
@@ -105,8 +103,6 @@ const plugin: NativePlugin = {
105
103
  ctx.registerTool(makeScallopPosition(onchain) as ToolDef)
106
104
  ctx.registerTool(makeScallopSupply(onchain) as ToolDef)
107
105
  ctx.registerTool(makeScallopWithdraw(onchain) as ToolDef)
108
- ctx.registerTool(makeScallopBorrow(onchain) as ToolDef)
109
- ctx.registerTool(makeScallopRepay(onchain) as ToolDef)
110
106
  ctx.registerTool(makeNaviMarkets(onchain) as ToolDef)
111
107
  ctx.registerTool(makeNaviPosition(onchain) as ToolDef)
112
108
  ctx.registerTool(makeNaviSupply(onchain) as ToolDef)
package/src/protocols.ts CHANGED
@@ -51,16 +51,9 @@ export const PROTOCOLS: ProtocolCapability[] = [
51
51
  category: 'lending',
52
52
  read: true,
53
53
  execute: true,
54
- tools: [
55
- 'scallop.markets',
56
- 'scallop.position',
57
- 'scallop.supply',
58
- 'scallop.withdraw',
59
- 'scallop.borrow',
60
- 'scallop.repay',
61
- ],
54
+ tools: ['scallop.markets', 'scallop.position', 'scallop.supply', 'scallop.withdraw'],
62
55
  llamaProjects: ['scallop-lending', 'scallop'],
63
- note: 'full supply / withdraw / borrow / repay',
56
+ note: 'supply / withdraw lending yield (borrow/repay via NAVI or Suilend)',
64
57
  },
65
58
  {
66
59
  id: 'navi',
@@ -129,32 +129,30 @@ 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,
135
- kind: 'supply' | 'withdraw' | 'borrow' | 'repay',
139
+ kind: 'supply' | 'withdraw',
136
140
  ): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> {
137
141
  const err = ensureMainnet(ctx)
138
142
  if (err) return { ok: false, error: err }
139
143
  const amountMist = suiToMist(amount)
140
144
  if (amountMist === undefined || amountMist <= 0n)
141
145
  return { ok: false, error: `invalid amount "${amount}"` }
142
- // Minimum guard for value-moving actions (withdraw pulls the agent's own funds).
143
- const minAction = kind === 'borrow' ? 'borrow' : kind === 'withdraw' ? null : 'supply'
144
- if (minAction) {
145
- const tooSmall = checkMinimum(minAction, amountMist)
146
+ // Minimum guard on supply (withdraw pulls the agent's own funds back).
147
+ if (kind === 'supply') {
148
+ const tooSmall = checkMinimum('supply', amountMist)
146
149
  if (tooSmall) return { ok: false, error: tooSmall }
147
150
  }
148
151
 
149
- // Policy gate on value-moving actions (borrow creates debt + hands out funds).
150
- if (ctx.policy && kind !== 'withdraw') {
152
+ // Policy gate on the value-moving supply.
153
+ if (ctx.policy && kind === 'supply') {
151
154
  const verdict = evaluatePolicy(
152
- {
153
- kind: 'transfer',
154
- coinType: SUI_TYPE,
155
- amountMist,
156
- protocol: kind === 'borrow' ? 'borrow' : 'scallop',
157
- },
155
+ { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'scallop' },
158
156
  ctx.policy,
159
157
  )
160
158
  if (!verdict.allowed)
@@ -166,15 +164,34 @@ async function runScallopWrite(
166
164
  const builder = await sdk.createScallopBuilder()
167
165
  const tx = builder.createTxBlock()
168
166
  tx.setSender(ctx.agentAddress)
169
- // *Quick helpers auto-select the user's coins/obligation. supply/withdraw and
170
- // borrow return a coin to hand back; repay consumes coins. depositQuick's 3rd
171
- // arg returnSCoin=false keeps the old market-coin standard (withdrawQuick
172
- // redeems it; the new sCoin standard → "No valid coins").
167
+ // depositQuick's 3rd arg returnSCoin=false keeps the old market-coin standard.
173
168
  let out: unknown
174
- if (kind === 'supply') out = await tx.depositQuick(Number(amountMist), 'sui', false)
175
- else if (kind === 'withdraw') out = await tx.withdrawQuick(Number(amountMist), 'sui')
176
- else if (kind === 'borrow') out = await tx.borrowQuick(Number(amountMist), 'sui')
177
- else await tx.repayQuick(Number(amountMist), 'sui')
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
+ }
178
195
  if (out) tx.transferObjects([out as never], ctx.agentAddress)
179
196
  const transaction = tx.txBlock
180
197
 
@@ -229,23 +246,8 @@ export function makeScallopWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountA
229
246
  }
230
247
  }
231
248
 
232
- export function makeScallopBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
233
- return {
234
- name: 'scallop.borrow',
235
- description:
236
- 'Borrow SUI from Scallop against supplied collateral. Requires an existing supply position with enough health; the pre-flight simulation fails cleanly if under-collateralized. Policy-checked, simulated, then executed.',
237
- searchHint: 'scallop borrow loan leverage debt against collateral sui',
238
- schema: AmountSchema,
239
- handler: async args => runScallopWrite(ctx, args.amount, 'borrow'),
240
- }
241
- }
242
-
243
- export function makeScallopRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
244
- return {
245
- name: 'scallop.repay',
246
- description: 'Repay borrowed SUI debt on Scallop. Simulated, then executed.',
247
- searchHint: 'scallop repay pay back debt loan close sui',
248
- schema: AmountSchema,
249
- handler: async args => runScallopWrite(ctx, args.amount, 'repay'),
250
- }
251
- }
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.
@@ -57,19 +57,31 @@ function isSuiType(t: string): boolean {
57
57
  return t === SUI_TYPE || t === SUI_LONG
58
58
  }
59
59
 
60
- /** The agent's obligation cap + id, or null if it has no Suilend position yet. */
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. */
61
64
  async function findObligation(
62
65
  ctx: OnchainRuntimeContext,
63
66
  ): Promise<{ capId: string; obligationId: string } | null> {
64
- const caps = await SuilendClient.getObligationOwnerCaps(
65
- ctx.agentAddress,
66
- [LENDING_MARKET_TYPE],
67
- ctx.client as never,
68
- )
69
- if (!caps.length) return null
70
- const c = caps[0] as { id: unknown; obligationId: string | { id: string } }
71
- const obligationId = typeof c.obligationId === 'string' ? c.obligationId : c.obligationId.id
72
- return { capId: capObjectId(c), obligationId }
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
73
85
  }
74
86
 
75
87
  // --- suilend.supply --------------------------------------------------------
@@ -79,6 +91,43 @@ const AmountSchema = z.object({
79
91
  })
80
92
  type AmountArgs = z.infer<typeof AmountSchema>
81
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
+
82
131
  export function makeSuilendSupply(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
83
132
  return {
84
133
  name: 'suilend.supply',
@@ -217,24 +266,25 @@ export function makeSuilendWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountA
217
266
 
218
267
  // --- suilend.borrow --------------------------------------------------------
219
268
 
220
- export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
269
+ export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<BorrowArgs> {
221
270
  return {
222
271
  name: 'suilend.borrow',
223
272
  description:
224
- 'Borrow SUI from Suilend against supplied collateral (amount in underlying SUI). Requires an existing obligation with enough health; the pre-flight simulation fails cleanly if under-collateralized. Policy-checked, simulated, then executed.',
225
- searchHint: 'suilend borrow loan leverage debt against collateral sui money market',
226
- schema: AmountSchema,
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,
227
276
  handler: async args => {
228
277
  const err = ensureMainnet(ctx)
229
278
  if (err) return { ok: false, error: err }
230
- const amountMist = suiToMist(args.amount)
231
- if (amountMist === undefined || amountMist <= 0n)
279
+ const coin = BORROW_COINS[args.coin ?? 'usdc']
280
+ const amountBase = toBaseUnits(args.amount, coin.decimals)
281
+ if (amountBase === undefined || amountBase <= 0n)
232
282
  return { ok: false, error: `invalid amount "${args.amount}"` }
233
- const tooSmall = checkMinimum('borrow', amountMist)
234
- if (tooSmall) return { ok: false, error: tooSmall }
283
+ if (amountBase < coin.minBase)
284
+ return { ok: false, error: `amount too small: below the minimum ${coin.label} borrow` }
235
285
  if (ctx.policy) {
236
286
  const verdict = evaluatePolicy(
237
- { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'borrow' },
287
+ { kind: 'transfer', coinType: coin.type, amountMist: amountBase, protocol: 'borrow' },
238
288
  ctx.policy,
239
289
  )
240
290
  if (!verdict.allowed)
@@ -252,8 +302,8 @@ export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArg
252
302
  ctx.agentAddress,
253
303
  obligation.capId,
254
304
  obligation.obligationId,
255
- SUI_TYPE,
256
- amountMist.toString(),
305
+ coin.type,
306
+ amountBase.toString(),
257
307
  tx as never,
258
308
  )
259
309
  const sim = await simulate(ctx.client, tx, ctx.agentAddress)
@@ -274,7 +324,8 @@ export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArg
274
324
  data: {
275
325
  protocol: 'suilend',
276
326
  action: 'borrow',
277
- amountSui: args.amount,
327
+ amount: args.amount,
328
+ coin: coin.label,
278
329
  digest: res.digest,
279
330
  policyEnforced: ctx.policy != null,
280
331
  },
@@ -288,22 +339,23 @@ export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArg
288
339
 
289
340
  // --- suilend.repay ---------------------------------------------------------
290
341
 
291
- export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
342
+ export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<BorrowArgs> {
292
343
  return {
293
344
  name: 'suilend.repay',
294
345
  description:
295
- 'Repay borrowed SUI debt on Suilend (amount in underlying SUI). Simulated, then executed.',
296
- searchHint: 'suilend repay pay back debt loan close sui money market',
297
- schema: AmountSchema,
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,
298
349
  handler: async args => {
299
350
  const err = ensureMainnet(ctx)
300
351
  if (err) return { ok: false, error: err }
301
- const amountMist = suiToMist(args.amount)
302
- if (amountMist === undefined || amountMist <= 0n)
352
+ const coin = BORROW_COINS[args.coin ?? 'usdc']
353
+ const amountBase = toBaseUnits(args.amount, coin.decimals)
354
+ if (amountBase === undefined || amountBase <= 0n)
303
355
  return { ok: false, error: `invalid amount "${args.amount}"` }
304
356
  if (ctx.policy) {
305
357
  const verdict = evaluatePolicy(
306
- { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'suilend' },
358
+ { kind: 'transfer', coinType: coin.type, amountMist: amountBase, protocol: 'suilend' },
307
359
  ctx.policy,
308
360
  )
309
361
  if (!verdict.allowed)
@@ -318,8 +370,8 @@ export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs
318
370
  await suilend.repayIntoObligation(
319
371
  ctx.agentAddress,
320
372
  obligation.obligationId,
321
- SUI_TYPE,
322
- amountMist.toString(),
373
+ coin.type,
374
+ amountBase.toString(),
323
375
  tx as never,
324
376
  )
325
377
  const sim = await simulate(ctx.client, tx, ctx.agentAddress)
@@ -340,7 +392,8 @@ export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs
340
392
  data: {
341
393
  protocol: 'suilend',
342
394
  action: 'repay',
343
- amountSui: args.amount,
395
+ amount: args.amount,
396
+ coin: coin.label,
344
397
  digest: res.digest,
345
398
  },
346
399
  }
@@ -15,7 +15,7 @@ import type { PluginContext, ToolDef } from 'lyra-core'
15
15
  import plugin from '../index'
16
16
  import type { OnchainRuntimeContext } from '../types'
17
17
  import { makeVoloStake } from './liquid-stake'
18
- import { makeScallopBorrow, makeScallopRepay } from './scallop'
18
+ import { makeNaviBorrow, makeNaviRepay } from './navi'
19
19
  import { makeSuilendBorrow, makeSuilendSupply } from './suilend'
20
20
 
21
21
  // A context whose network/amount guards resolve before any client access.
@@ -56,8 +56,6 @@ describe('onchain plugin registration', () => {
56
56
  // lending: the three biggest Sui money markets, full CRUD
57
57
  'scallop.supply',
58
58
  'scallop.withdraw',
59
- 'scallop.borrow',
60
- 'scallop.repay',
61
59
  'navi.supply',
62
60
  'navi.borrow',
63
61
  'navi.repay',
@@ -112,8 +110,8 @@ describe('write-tool guards (fail before network)', () => {
112
110
  if (!res.ok) expect(res.error).toContain('amount too small')
113
111
  })
114
112
 
115
- test('scallop.borrow rejects a dust amount', async () => {
116
- const res = await makeScallopBorrow(stubCtx()).handler({ amount: '0.001' })
113
+ test('navi.borrow rejects a dust amount', async () => {
114
+ const res = await makeNaviBorrow(stubCtx()).handler({ amount: '0.001' })
117
115
  expect(res.ok).toBe(false)
118
116
  if (!res.ok) expect(res.error).toContain('amount too small')
119
117
  })
@@ -134,9 +132,9 @@ describe('write-tool guards (fail before network)', () => {
134
132
  if (!stake.ok) expect(stake.error).toContain('mainnet only')
135
133
  })
136
134
 
137
- test('scallop.repay is a registered, well-formed tool', () => {
138
- const tool = makeScallopRepay(stubCtx())
139
- expect(tool.name).toBe('scallop.repay')
135
+ test('navi.repay is a registered, well-formed tool', () => {
136
+ const tool = makeNaviRepay(stubCtx())
137
+ expect(tool.name).toBe('navi.repay')
140
138
  expect(typeof tool.handler).toBe('function')
141
139
  })
142
140
  })