lyra-plugin-onchain 0.1.2 → 0.1.7

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.2",
3
+ "version": "0.1.7",
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",
@@ -0,0 +1,20 @@
1
+ // Bun's `Error.captureStackTrace` throws "First argument must be an Error object"
2
+ // for some calls `follow-redirects` makes at module-init time. follow-redirects is
3
+ // pulled in by axios → navi-sdk (imported statically by ./tools/navi), so loading
4
+ // this plugin under Bun used to crash the host (e.g. `lyra` chat, the gateway).
5
+ //
6
+ // Guard captureStackTrace so such calls are a harmless no-op instead of throwing —
7
+ // the affected error objects just won't carry a V8-captured stack. MUST be the
8
+ // first import in this package's entry, before anything pulls in navi-sdk.
9
+ const original = Error.captureStackTrace
10
+ if (typeof original === 'function') {
11
+ Error.captureStackTrace = ((target: object, ctor?: unknown) => {
12
+ try {
13
+ ;(original as (t: object, c?: unknown) => void).call(Error, target, ctor)
14
+ } catch {
15
+ // Bun rejects some non-Error targets here — ignore.
16
+ }
17
+ }) as typeof Error.captureStackTrace
18
+ }
19
+
20
+ export {}
package/src/index.ts CHANGED
@@ -17,12 +17,22 @@
17
17
  * nothing — a graceful no-op for unit-test loaders.
18
18
  */
19
19
 
20
+ // MUST be first: guards Error.captureStackTrace before ./tools/navi pulls in
21
+ // navi-sdk → axios → follow-redirects, which crashes Bun at import otherwise.
22
+ import './capture-shim'
20
23
  import type { NativePlugin, ToolDef } from 'lyra-core'
21
24
  import { makeAccountInfo, makeSuiBalance } from './tools/balance'
22
25
  import { makeCetusQuote } from './tools/cetus'
23
26
  import { makeDeepbookMarkets } from './tools/deepbook'
24
27
  import { makeDefiYields } from './tools/defillama'
25
- import { makeNaviMarkets, makeNaviPosition, makeNaviSupply, makeNaviWithdraw } from './tools/navi'
28
+ import {
29
+ makeNaviBorrow,
30
+ makeNaviMarkets,
31
+ makeNaviPosition,
32
+ makeNaviRepay,
33
+ makeNaviSupply,
34
+ makeNaviWithdraw,
35
+ } from './tools/navi'
26
36
  import { makePolicyCreate, makePolicyShow } from './tools/policy'
27
37
  import { makeProtocolsList } from './tools/protocols'
28
38
  import {
@@ -32,6 +42,7 @@ import {
32
42
  makeScallopWithdraw,
33
43
  } from './tools/scallop'
34
44
  import { makeSuiSend } from './tools/send'
45
+ import { makeStake, makeUnstake } from './tools/stake'
35
46
  import { makeSwap } from './tools/swap'
36
47
  import { makeWalrusStore } from './tools/walrus'
37
48
  import type { OnchainRuntimeContext } from './types'
@@ -88,6 +99,12 @@ const plugin: NativePlugin = {
88
99
  ctx.registerTool(makeNaviPosition(onchain) as ToolDef)
89
100
  ctx.registerTool(makeNaviSupply(onchain) as ToolDef)
90
101
  ctx.registerTool(makeNaviWithdraw(onchain) as ToolDef)
102
+ ctx.registerTool(makeNaviBorrow(onchain) as ToolDef)
103
+ ctx.registerTool(makeNaviRepay(onchain) as ToolDef)
104
+
105
+ // Native staking (delegate SUI to a validator; min 1 SUI).
106
+ ctx.registerTool(makeStake(onchain) as ToolDef)
107
+ ctx.registerTool(makeUnstake(onchain) as ToolDef)
91
108
  },
92
109
  }
93
110
 
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { MIN_MIST, checkMinimum } from './minimums'
3
+
4
+ describe('checkMinimum', () => {
5
+ test('rejects a too-small stake (0.1 SUI < 1 SUI minimum)', () => {
6
+ const err = checkMinimum('stake', 100_000_000n) // 0.1 SUI
7
+ expect(err).not.toBeNull()
8
+ expect(err).toContain('amount too small')
9
+ expect(err).toContain('1 SUI minimum for stake')
10
+ })
11
+
12
+ test('accepts a stake at the 1 SUI minimum', () => {
13
+ expect(checkMinimum('stake', MIN_MIST.stake)).toBeNull()
14
+ expect(checkMinimum('stake', 2_000_000_000n)).toBeNull()
15
+ })
16
+
17
+ test('rejects dust transfers/swaps/supplies below their minimums', () => {
18
+ expect(checkMinimum('transfer', 1n)).not.toBeNull()
19
+ expect(checkMinimum('swap', 1_000_000n)).not.toBeNull() // 0.001 < 0.01
20
+ expect(checkMinimum('supply', 5_000_000n)).not.toBeNull() // 0.005 < 0.01
21
+ expect(checkMinimum('borrow', 9_999_999n)).not.toBeNull()
22
+ })
23
+
24
+ test('accepts amounts at/above the minimum', () => {
25
+ expect(checkMinimum('transfer', MIN_MIST.transfer)).toBeNull()
26
+ expect(checkMinimum('swap', MIN_MIST.swap)).toBeNull()
27
+ expect(checkMinimum('supply', MIN_MIST.supply)).toBeNull()
28
+ expect(checkMinimum('borrow', MIN_MIST.borrow)).toBeNull()
29
+ })
30
+
31
+ test('the message names the amount, the minimum, and the action', () => {
32
+ const err = checkMinimum('swap', 5_000_000n) // 0.005 SUI
33
+ expect(err).toContain('0.005 SUI')
34
+ expect(err).toContain('0.01 SUI minimum for swap')
35
+ })
36
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Per-action minimum amounts (in MIST). Below these an on-chain call either
3
+ * hard-aborts (Sui native staking requires ≥ 1 SUI) or is economically
4
+ * pointless (dust deposits). We reject BEFORE building/simulating/executing and
5
+ * tell the user the minimum — so a too-small amount returns a clear
6
+ * "amount too small" error instead of a doomed transaction that could otherwise
7
+ * look like it succeeded.
8
+ */
9
+ export const MIN_MIST = {
10
+ transfer: 1_000_000n, // 0.001 SUI
11
+ swap: 10_000_000n, // 0.01 SUI
12
+ supply: 10_000_000n, // 0.01 SUI (lending deposit)
13
+ borrow: 10_000_000n, // 0.01 SUI
14
+ stake: 1_000_000_000n, // 1 SUI (Sui native staking hard minimum)
15
+ } as const
16
+
17
+ export type MinAction = keyof typeof MIN_MIST
18
+
19
+ const fmtSui = (mist: bigint): string => {
20
+ const s = (Number(mist) / 1e9).toString()
21
+ return s
22
+ }
23
+
24
+ /**
25
+ * Returns an error string when `amountMist` is below the action's minimum,
26
+ * otherwise null. Callers should early-return the string as `{ ok: false, error }`
27
+ * BEFORE executing anything.
28
+ */
29
+ export function checkMinimum(action: MinAction, amountMist: bigint): string | null {
30
+ const min = MIN_MIST[action]
31
+ if (amountMist < min) {
32
+ return `amount too small: ${fmtSui(amountMist)} SUI is below the ${fmtSui(min)} SUI minimum for ${action} — try at least ${fmtSui(min)} SUI`
33
+ }
34
+ return null
35
+ }
package/src/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,25 @@ type AmountArgs = z.infer<typeof AmountSchema>
107
108
  async function runNaviWrite(
108
109
  ctx: OnchainRuntimeContext,
109
110
  amount: string,
110
- kind: 'supply' | 'withdraw',
111
+ kind: 'supply' | 'withdraw' | 'borrow' | 'repay',
111
112
  ): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> {
112
113
  const err = ensureMainnet(ctx)
113
114
  if (err) return { ok: false, error: err }
114
115
  const amountMist = suiToMist(amount)
115
116
  if (amountMist === undefined || amountMist <= 0n)
116
117
  return { ok: false, error: `invalid amount "${amount}"` }
118
+ // Minimum guard for value-moving actions (withdraw brings value back in).
119
+ const minAction = kind === 'borrow' ? 'borrow' : kind === 'withdraw' ? null : 'supply'
120
+ if (minAction) {
121
+ const tooSmall = checkMinimum(minAction, amountMist)
122
+ if (tooSmall) return { ok: false, error: tooSmall }
123
+ }
117
124
 
118
- if (ctx.policy && kind === 'supply') {
125
+ // Policy gate on value-moving actions (borrow creates debt + hands out funds;
126
+ // repay/supply move SUI out). Withdraw pulls the agent's own funds back.
127
+ if (ctx.policy && kind !== 'withdraw') {
119
128
  const verdict = evaluatePolicy(
120
- { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'navi' },
129
+ { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: kind === 'borrow' ? 'borrow' : 'navi' },
121
130
  ctx.policy,
122
131
  )
123
132
  if (!verdict.allowed)
@@ -130,11 +139,19 @@ async function runNaviWrite(
130
139
  if (kind === 'supply') {
131
140
  const [coin] = tx.splitCoins(tx.gas, [amountMist])
132
141
  await depositCoin(tx as never, suiPool as never, coin as never, Number(amountMist))
133
- } else {
142
+ } else if (kind === 'withdraw') {
134
143
  // navi-sdk's withdrawCoin already wraps the withdrawn Balance into a Coin
135
144
  // (via coin::from_balance) and returns [coin]; destructure and transfer it.
136
145
  const [coin] = await withdrawCoin(tx as never, suiPool as never, Number(amountMist))
137
146
  tx.transferObjects([coin as never], ctx.agentAddress)
147
+ } else if (kind === 'borrow') {
148
+ // Borrow against supplied collateral; hand the borrowed coin to the agent.
149
+ const [coin] = await borrowCoin(tx as never, suiPool as never, Number(amountMist))
150
+ tx.transferObjects([coin as never], ctx.agentAddress)
151
+ } else {
152
+ // repay: split the repayment from gas and pay down the debt.
153
+ const [coin] = tx.splitCoins(tx.gas, [amountMist])
154
+ await repayDebt(tx as never, suiPool as never, coin as never, Number(amountMist))
138
155
  }
139
156
 
140
157
  const sim = await simulate(ctx.client, tx, ctx.agentAddress)
@@ -187,3 +204,24 @@ export function makeNaviWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountArgs
187
204
  handler: async args => runNaviWrite(ctx, args.amount, 'withdraw'),
188
205
  }
189
206
  }
207
+
208
+ export function makeNaviBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
209
+ return {
210
+ name: 'navi.borrow',
211
+ description:
212
+ '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.',
213
+ searchHint: 'navi borrow loan leverage debt against collateral sui',
214
+ schema: AmountSchema,
215
+ handler: async args => runNaviWrite(ctx, args.amount, 'borrow'),
216
+ }
217
+ }
218
+
219
+ export function makeNaviRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
220
+ return {
221
+ name: 'navi.repay',
222
+ description: 'Repay borrowed SUI debt on NAVI. Simulated, then executed.',
223
+ searchHint: 'navi repay pay back debt loan close sui',
224
+ schema: AmountSchema,
225
+ handler: async args => runNaviWrite(ctx, args.amount, 'repay'),
226
+ }
227
+ }
@@ -12,6 +12,7 @@
12
12
  import { Scallop } from '@scallop-io/sui-scallop-sdk'
13
13
  import type { ToolDef } from 'lyra-core'
14
14
  import { z } from 'zod'
15
+ import { checkMinimum } from '../minimums'
15
16
  import { evaluatePolicy, suiToMist } from '../policy'
16
17
  import { simulate } from '../simulate'
17
18
  import type { OnchainRuntimeContext } from '../types'
@@ -138,6 +139,10 @@ async function runScallopWrite(
138
139
  const amountMist = suiToMist(amount)
139
140
  if (amountMist === undefined || amountMist <= 0n)
140
141
  return { ok: false, error: `invalid amount "${amount}"` }
142
+ if (kind === 'supply') {
143
+ const tooSmall = checkMinimum('supply', amountMist)
144
+ if (tooSmall) return { ok: false, error: tooSmall }
145
+ }
141
146
 
142
147
  // Policy gate (deterministic). Supplying moves funds out; withdrawing back in.
143
148
  if (ctx.policy && kind === 'supply') {
package/src/tools/send.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import { Transaction } from '@mysten/sui/transactions'
12
12
  import type { ToolDef } from 'lyra-core'
13
13
  import { z } from 'zod'
14
+ import { checkMinimum } from '../minimums'
14
15
  import { evaluatePolicy, suiToMist } from '../policy'
15
16
  import { simulate } from '../simulate'
16
17
  import type { OnchainRuntimeContext } from '../types'
@@ -40,6 +41,8 @@ export function makeSuiSend(ctx: OnchainRuntimeContext): ToolDef<Args> {
40
41
  if (amountMist === undefined || amountMist <= 0n) {
41
42
  return { ok: false, error: `invalid amount "${args.amount}"` }
42
43
  }
44
+ const tooSmall = checkMinimum('transfer', amountMist)
45
+ if (tooSmall) return { ok: false, error: tooSmall }
43
46
 
44
47
  // 1. Policy gate (deterministic) — block before simulate/execute.
45
48
  if (ctx.policy) {
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Sui native staking — delegate SUI to a validator and unstake.
3
+ *
4
+ * sui.stake → 0x3::sui_system::request_add_stake (min 1 SUI, hard on-chain)
5
+ * sui.unstake → 0x3::sui_system::request_withdraw_stake
6
+ *
7
+ * Same guarded pipeline as the other write tools: minimum-amount guard →
8
+ * deterministic policy → dry-run simulate → execute → on-chain effects check.
9
+ * Sui native staking rejects anything below 1 SUI, so a too-small amount returns
10
+ * a clear "amount too small" error BEFORE any transaction is built.
11
+ */
12
+
13
+ import { Transaction } from '@mysten/sui/transactions'
14
+ import type { ToolDef } from 'lyra-core'
15
+ import { z } from 'zod'
16
+ import { checkMinimum } from '../minimums'
17
+ import { evaluatePolicy, suiToMist } from '../policy'
18
+ import { simulate } from '../simulate'
19
+ import type { OnchainRuntimeContext } from '../types'
20
+
21
+ const SUI_TYPE = '0x2::sui::SUI'
22
+ const SUI_SYSTEM_STATE = '0x5'
23
+ const STAKED_SUI_TYPE = '0x3::staking_pool::StakedSui'
24
+
25
+ /** Resolve a validator: match `want` by address/name, else pick a large active one. */
26
+ async function resolveValidator(
27
+ client: OnchainRuntimeContext['client'],
28
+ want?: string,
29
+ ): Promise<{ address: string; name: string } | null> {
30
+ const state = await client.getLatestSuiSystemState()
31
+ // biome-ignore lint/suspicious/noExplicitAny: SuiSystemState validator fields
32
+ const vals = (state.activeValidators ?? []) as any[]
33
+ if (vals.length === 0) return null
34
+ if (want && want.trim()) {
35
+ const w = want.trim().toLowerCase()
36
+ const m = vals.find(
37
+ v => String(v.suiAddress).toLowerCase() === w || String(v.name ?? '').toLowerCase() === w,
38
+ )
39
+ if (m) return { address: m.suiAddress, name: m.name ?? '' }
40
+ // A raw 0x address the user gave is honored even if not in the top set.
41
+ if (w.startsWith('0x')) return { address: want.trim(), name: '' }
42
+ return null
43
+ }
44
+ // Default: the validator with the most voting power (a large, reliable one).
45
+ const best = vals.reduce((a, b) => (Number(b.votingPower) > Number(a.votingPower) ? b : a))
46
+ return { address: best.suiAddress, name: best.name ?? '' }
47
+ }
48
+
49
+ const StakeSchema = z.object({
50
+ amount: z.string().min(1).describe('Amount of SUI to stake (minimum 1 SUI).'),
51
+ validator: z
52
+ .string()
53
+ .optional()
54
+ .describe('Validator address (0x…) or name. Optional — defaults to a large active validator.'),
55
+ })
56
+ type StakeArgs = z.infer<typeof StakeSchema>
57
+
58
+ export function makeStake(ctx: OnchainRuntimeContext): ToolDef<StakeArgs> {
59
+ return {
60
+ name: 'sui.stake',
61
+ description:
62
+ 'Stake SUI to a validator (Sui native staking) to earn staking rewards. Minimum 1 SUI (enforced by the network). Policy-checked → simulated → executed; returns a StakedSui object.',
63
+ searchHint: 'stake staking delegate validator earn rewards yield sui native',
64
+ schema: StakeSchema,
65
+ handler: async args => {
66
+ try {
67
+ const amountMist = suiToMist(args.amount)
68
+ if (amountMist === undefined || amountMist <= 0n) {
69
+ return { ok: false, error: `invalid amount "${args.amount}"` }
70
+ }
71
+ const tooSmall = checkMinimum('stake', amountMist)
72
+ if (tooSmall) return { ok: false, error: tooSmall }
73
+
74
+ if (ctx.policy) {
75
+ const verdict = evaluatePolicy(
76
+ { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'stake' },
77
+ ctx.policy,
78
+ )
79
+ if (!verdict.allowed) {
80
+ return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
81
+ }
82
+ }
83
+
84
+ const validator = await resolveValidator(ctx.client, args.validator)
85
+ if (!validator) return { ok: false, error: 'no active validator found to stake with' }
86
+
87
+ const tx = new Transaction()
88
+ const [coin] = tx.splitCoins(tx.gas, [amountMist])
89
+ tx.moveCall({
90
+ target: '0x3::sui_system::request_add_stake',
91
+ arguments: [tx.object(SUI_SYSTEM_STATE), coin, tx.pure.address(validator.address)],
92
+ })
93
+
94
+ const sim = await simulate(ctx.client, tx, ctx.agentAddress)
95
+ if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
96
+
97
+ const res = await ctx.client.signAndExecuteTransaction({
98
+ signer: ctx.keypair,
99
+ transaction: tx,
100
+ options: { showEffects: true, showObjectChanges: true },
101
+ })
102
+ if (res.effects?.status?.status !== 'success') {
103
+ return { ok: false, error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}` }
104
+ }
105
+ await ctx.client.waitForTransaction({ digest: res.digest })
106
+ const staked = res.objectChanges?.find(
107
+ c =>
108
+ c.type === 'created' &&
109
+ String((c as { objectType?: string }).objectType).includes('staking_pool::StakedSui'),
110
+ ) as { objectId?: string } | undefined
111
+
112
+ return {
113
+ ok: true,
114
+ data: {
115
+ protocol: 'sui-staking',
116
+ action: 'stake',
117
+ amountSui: args.amount,
118
+ validator: validator.address,
119
+ validatorName: validator.name || undefined,
120
+ digest: res.digest,
121
+ stakedSuiId: staked?.objectId ?? null,
122
+ status: 'success',
123
+ },
124
+ }
125
+ } catch (e) {
126
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
127
+ }
128
+ },
129
+ }
130
+ }
131
+
132
+ const UnstakeSchema = z.object({
133
+ stakedSuiId: z
134
+ .string()
135
+ .optional()
136
+ .describe('StakedSui object id to withdraw. Optional — defaults to the first staked position.'),
137
+ })
138
+ type UnstakeArgs = z.infer<typeof UnstakeSchema>
139
+
140
+ export function makeUnstake(ctx: OnchainRuntimeContext): ToolDef<UnstakeArgs> {
141
+ return {
142
+ name: 'sui.unstake',
143
+ description:
144
+ 'Withdraw staked SUI (unstake) from a validator, returning principal + earned rewards. Uses a specific StakedSui object id, or the first staked position if omitted.',
145
+ searchHint: 'unstake withdraw staking redeem claim rewards validator',
146
+ schema: UnstakeSchema,
147
+ handler: async args => {
148
+ try {
149
+ let stakedId = args.stakedSuiId?.trim()
150
+ if (!stakedId) {
151
+ const owned = await ctx.client.getOwnedObjects({
152
+ owner: ctx.agentAddress,
153
+ filter: { StructType: STAKED_SUI_TYPE },
154
+ })
155
+ stakedId = owned.data[0]?.data?.objectId
156
+ if (!stakedId) return { ok: false, error: 'no staked SUI position found to unstake' }
157
+ }
158
+
159
+ if (ctx.policy) {
160
+ const verdict = evaluatePolicy(
161
+ { kind: 'transfer', coinType: SUI_TYPE, amountMist: 0n, protocol: 'stake' },
162
+ ctx.policy,
163
+ )
164
+ if (!verdict.allowed) {
165
+ return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
166
+ }
167
+ }
168
+
169
+ const tx = new Transaction()
170
+ tx.moveCall({
171
+ target: '0x3::sui_system::request_withdraw_stake',
172
+ arguments: [tx.object(SUI_SYSTEM_STATE), tx.object(stakedId)],
173
+ })
174
+
175
+ const sim = await simulate(ctx.client, tx, ctx.agentAddress)
176
+ if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
177
+
178
+ const res = await ctx.client.signAndExecuteTransaction({
179
+ signer: ctx.keypair,
180
+ transaction: tx,
181
+ options: { showEffects: true },
182
+ })
183
+ if (res.effects?.status?.status !== 'success') {
184
+ return { ok: false, error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}` }
185
+ }
186
+ await ctx.client.waitForTransaction({ digest: res.digest })
187
+
188
+ return {
189
+ ok: true,
190
+ data: {
191
+ protocol: 'sui-staking',
192
+ action: 'unstake',
193
+ stakedSuiId: stakedId,
194
+ digest: res.digest,
195
+ status: 'success',
196
+ },
197
+ }
198
+ } catch (e) {
199
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
200
+ }
201
+ },
202
+ }
203
+ }
package/src/tools/swap.ts CHANGED
@@ -13,6 +13,7 @@ import { MetaAg } from '@7kprotocol/sdk-ts'
13
13
  import { Transaction, coinWithBalance } from '@mysten/sui/transactions'
14
14
  import type { ToolDef } from 'lyra-core'
15
15
  import { z } from 'zod'
16
+ import { checkMinimum } from '../minimums'
16
17
  import { evaluatePolicy } from '../policy'
17
18
  import { simulate } from '../simulate'
18
19
  import type { OnchainRuntimeContext } from '../types'
@@ -63,6 +64,11 @@ export function makeSwap(ctx: OnchainRuntimeContext): ToolDef<Args> {
63
64
  if (from.type === to.type) return { ok: false, error: 'from and to are the same coin' }
64
65
  const amountIn = BigInt(Math.round(Number(args.amount) * 10 ** from.decimals))
65
66
  if (amountIn <= 0n) return { ok: false, error: `invalid amount "${args.amount}"` }
67
+ // Minimum only bounds SUI-denominated input (amountIn is then in MIST).
68
+ if (from.type === SUI_TYPE) {
69
+ const tooSmall = checkMinimum('swap', amountIn)
70
+ if (tooSmall) return { ok: false, error: tooSmall }
71
+ }
66
72
 
67
73
  // Policy gate. The MIST per-tx cap is SUI-denominated, so it only bounds
68
74
  // SUI-input swaps; the slippage/protocol/expiry checks always apply.