lyra-plugin-onchain 0.1.9 → 0.1.10

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.9",
3
+ "version": "0.1.10",
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",
@@ -37,7 +37,7 @@
37
37
  "@scallop-io/sui-scallop-sdk": "^2.4.5",
38
38
  "@suilend/sdk": "1.1.99",
39
39
  "@suilend/sui-fe": "0.3.49",
40
- "lyra-core": "^0.1.2",
40
+ "lyra-core": "^0.1.10",
41
41
  "navi-sdk": "^1.7.3",
42
42
  "zod": "^3.23.8"
43
43
  }
package/src/index.ts CHANGED
@@ -74,9 +74,21 @@ export {
74
74
  } from './policy'
75
75
  export { policyRequiresApprovalForCall } from './approval'
76
76
  export { deriveAgentKeypair, deriveAgentAddress } from './derive'
77
- export { resolveOwnerVault, type OwnerVault } from './vault'
77
+ export {
78
+ resolveOwnerVault,
79
+ resolveVaultForAgent,
80
+ type OwnerVault,
81
+ type AgentVault,
82
+ } from './vault'
78
83
  export { ONCHAIN_GUIDANCE } from './guidance'
79
84
  export type { OnchainRuntimeContext } from './types'
85
+ export {
86
+ PROTOCOL_IDS,
87
+ PROTOCOL_LABELS,
88
+ ALLOWLISTABLE_PROTOCOLS,
89
+ NO_PROTOCOL,
90
+ type ProtocolKey,
91
+ } from './protocol-ids'
80
92
 
81
93
  const plugin: NativePlugin = {
82
94
  name: 'onchain',
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Canonical on-chain protocol ids for the policy's protocol allowlist.
3
+ *
4
+ * When a tool draws from the vault via `vault_spend`, it tags the action with the
5
+ * protocol's package id. The owner's `allowed_protocols` allowlist (settable via
6
+ * `lyra::policy::set_allowed_protocols`) is checked against this tag on-chain — so
7
+ * an owner can restrict the agent to, say, only staking + NAVI. These ids are the
8
+ * SINGLE SOURCE OF TRUTH shared by the tools (which tag) and the console UI (which
9
+ * lets the owner toggle) so the two never drift.
10
+ *
11
+ * They are STABLE labels: a protocol upgrading its package doesn't change the tag
12
+ * (the tag is a self-reported label for the allowlist, not the call target), so an
13
+ * owner's allowlist keeps working across protocol upgrades. Sourced from each
14
+ * protocol's SDK on mainnet.
15
+ *
16
+ * `0x0` is the reserved "no specific protocol" tag used by transfers + swaps —
17
+ * always allowed by the policy (they're bounded by budget/cap/coin/recipient/
18
+ * slippage instead), so restricting protocols never blocks a plain transfer/swap.
19
+ */
20
+ export const NO_PROTOCOL = '0x0'
21
+
22
+ export const PROTOCOL_IDS = {
23
+ suiStaking: '0x3',
24
+ navi: '0x1e4a13a0494d5facdbe8473e74127b838c2d446ecec0ce262e2eddafa77259cb',
25
+ suilend: '0xf95b06141ed4a174f239417323bde3f209b972f5930d8521ea38a52aff3a6ddf',
26
+ volo: '0x68d22cf8bdbcd11ecba1e094922873e4080d4d11133e2443fddda0bfd11dae20',
27
+ scallop: '0xde5c09ad171544aa3724dc67216668c80e754860f419136a68d78504eb2e2805',
28
+ } as const
29
+
30
+ export type ProtocolKey = keyof typeof PROTOCOL_IDS
31
+
32
+ /** Human labels for each allowlistable protocol (for the owner UI + receipts). */
33
+ export const PROTOCOL_LABELS: Record<string, string> = {
34
+ [PROTOCOL_IDS.suiStaking]: 'Sui staking',
35
+ [PROTOCOL_IDS.navi]: 'NAVI',
36
+ [PROTOCOL_IDS.suilend]: 'Suilend',
37
+ [PROTOCOL_IDS.volo]: 'Volo (vSUI)',
38
+ [PROTOCOL_IDS.scallop]: 'Scallop',
39
+ }
40
+
41
+ /** The allowlistable protocols in display order, for building the owner UI. */
42
+ export const ALLOWLISTABLE_PROTOCOLS: { key: ProtocolKey; id: string; label: string }[] = [
43
+ { key: 'suiStaking', id: PROTOCOL_IDS.suiStaking, label: 'Sui staking' },
44
+ { key: 'navi', id: PROTOCOL_IDS.navi, label: 'NAVI' },
45
+ { key: 'suilend', id: PROTOCOL_IDS.suilend, label: 'Suilend' },
46
+ { key: 'volo', id: PROTOCOL_IDS.volo, label: 'Volo (vSUI)' },
47
+ { key: 'scallop', id: PROTOCOL_IDS.scallop, label: 'Scallop' },
48
+ ]
@@ -16,8 +16,10 @@ import { stakeTovSuiPTB, unstakeTovSui } from 'navi-sdk'
16
16
  import { z } from 'zod'
17
17
  import { checkMinimum } from '../minimums'
18
18
  import { evaluatePolicy, suiToMist } from '../policy'
19
+ import { PROTOCOL_IDS } from '../protocol-ids'
19
20
  import { simulate } from '../simulate'
20
21
  import type { OnchainRuntimeContext } from '../types'
22
+ import { fundSui } from '../vault-fund'
21
23
 
22
24
  const SUI_TYPE = '0x2::sui::SUI'
23
25
  const VSUI_TYPE = '0x549e8b69270defbfafd4f94e17ec44cdbdd99820b33bda2278dea3b9a32d3f55::cert::CERT'
@@ -61,7 +63,12 @@ export function makeVoloStake(ctx: OnchainRuntimeContext): ToolDef<StakeArgs> {
61
63
  }
62
64
 
63
65
  const tx = new Transaction()
64
- const [suiCoin] = tx.splitCoins(tx.gas, [amountMist])
66
+ // Source the stake from the treasury vault (policy-enforced) when wired.
67
+ const suiCoin = fundSui(tx, ctx, amountMist, {
68
+ protocol: PROTOCOL_IDS.volo,
69
+ kind: 'stake',
70
+ memo: 'volo liquid stake',
71
+ })
65
72
  const vsui = await stakeTovSuiPTB(tx as never, suiCoin as never)
66
73
  tx.transferObjects([vsui as never], ctx.agentAddress)
67
74
 
package/src/tools/navi.ts CHANGED
@@ -14,8 +14,10 @@ import { NAVISDKClient, borrowCoin, depositCoin, pool, repayDebt, withdrawCoin }
14
14
  import { z } from 'zod'
15
15
  import { checkMinimum } from '../minimums'
16
16
  import { evaluatePolicy, suiToMist } from '../policy'
17
+ import { PROTOCOL_IDS } from '../protocol-ids'
17
18
  import { simulate } from '../simulate'
18
19
  import type { OnchainRuntimeContext } from '../types'
20
+ import { fundSui, returnSuiToVault } from '../vault-fund'
19
21
 
20
22
  const SUI_TYPE = '0x2::sui::SUI'
21
23
 
@@ -142,20 +144,33 @@ async function runNaviWrite(
142
144
  const tx = new Transaction()
143
145
  const suiPool = (pool as Record<string, unknown>).Sui
144
146
  if (kind === 'supply') {
145
- const [coin] = tx.splitCoins(tx.gas, [amountMist])
147
+ // Source the supply from the treasury vault (policy-enforced) when wired.
148
+ const coin = fundSui(tx, ctx, amountMist, {
149
+ protocol: PROTOCOL_IDS.navi,
150
+ kind: 'supply',
151
+ memo: 'navi supply',
152
+ })
146
153
  await depositCoin(tx as never, suiPool as never, coin as never, Number(amountMist))
147
154
  } else if (kind === 'withdraw') {
148
155
  // navi-sdk's withdrawCoin already wraps the withdrawn Balance into a Coin
149
- // (via coin::from_balance) and returns [coin]; destructure and transfer it.
156
+ // (via coin::from_balance) and returns [coin]. Option 1: cycle it back into
157
+ // the vault (treasury stays intact); fall back to the agent when no vault.
150
158
  const [coin] = await withdrawCoin(tx as never, suiPool as never, Number(amountMist))
151
- tx.transferObjects([coin as never], ctx.agentAddress)
159
+ if (!returnSuiToVault(tx, ctx, coin as never))
160
+ tx.transferObjects([coin as never], ctx.agentAddress)
152
161
  } else if (kind === 'borrow') {
153
- // Borrow against supplied collateral; hand the borrowed coin to the agent.
162
+ // Borrow against supplied collateral; park the borrowed SUI in the vault
163
+ // (spendable under policy) when wired, else hand it to the agent.
154
164
  const [coin] = await borrowCoin(tx as never, suiPool as never, Number(amountMist))
155
- tx.transferObjects([coin as never], ctx.agentAddress)
165
+ if (!returnSuiToVault(tx, ctx, coin as never))
166
+ tx.transferObjects([coin as never], ctx.agentAddress)
156
167
  } else {
157
- // repay: split the repayment from gas and pay down the debt.
158
- const [coin] = tx.splitCoins(tx.gas, [amountMist])
168
+ // repay: draw the repayment from the vault (policy-enforced) and pay the debt.
169
+ const coin = fundSui(tx, ctx, amountMist, {
170
+ protocol: PROTOCOL_IDS.navi,
171
+ kind: 'repay',
172
+ memo: 'navi repay',
173
+ })
159
174
  await repayDebt(tx as never, suiPool as never, coin as never, Number(amountMist))
160
175
  }
161
176
 
@@ -14,8 +14,10 @@ import type { ToolDef } from 'lyra-core'
14
14
  import { z } from 'zod'
15
15
  import { checkMinimum } from '../minimums'
16
16
  import { evaluatePolicy, suiToMist } from '../policy'
17
+ import { PROTOCOL_IDS } from '../protocol-ids'
17
18
  import { simulate } from '../simulate'
18
19
  import type { OnchainRuntimeContext } from '../types'
20
+ import { fundSui, returnSuiToVault } from '../vault-fund'
19
21
 
20
22
  const SUI_TYPE = '0x2::sui::SUI'
21
23
 
@@ -164,10 +166,18 @@ async function runScallopWrite(
164
166
  const builder = await sdk.createScallopBuilder()
165
167
  const tx = builder.createTxBlock()
166
168
  tx.setSender(ctx.agentAddress)
167
- // depositQuick's 3rd arg returnSCoin=false keeps the old market-coin standard.
168
169
  let out: unknown
169
170
  if (kind === 'supply') {
170
- out = await tx.depositQuick(Number(amountMist), 'sui', false)
171
+ // Source the deposit from the treasury vault (policy-enforced) when wired,
172
+ // then hand the vault-drawn Coin<SUI> to Scallop's non-quick `deposit` (the
173
+ // `*Quick` helpers auto-select the agent's own coins, so they can't be vault-
174
+ // funded). `deposit` returns the market coin, sent to the agent below.
175
+ const coin = fundSui(tx.txBlock as never, ctx, amountMist, {
176
+ protocol: PROTOCOL_IDS.scallop,
177
+ kind: 'supply',
178
+ memo: 'scallop supply',
179
+ })
180
+ out = await tx.deposit(coin as never, 'sui')
171
181
  } else {
172
182
  // withdrawQuick redeems a MARKET-COIN amount, not underlying SUI. Convert
173
183
  // the requested SUI to market coins at the position's rate and clamp to the
@@ -192,7 +202,12 @@ async function runScallopWrite(
192
202
  if (redeemMc > heldMc || redeemMc <= 0n) redeemMc = heldMc
193
203
  out = await tx.withdrawQuick(Number(redeemMc), 'sui')
194
204
  }
195
- if (out) tx.transferObjects([out as never], ctx.agentAddress)
205
+ if (out) {
206
+ // Option 1: cycle withdrawn SUI back into the vault (treasury stays intact);
207
+ // supply's market coin and the no-vault case go to the agent.
208
+ const cycled = kind === 'withdraw' && returnSuiToVault(tx.txBlock as never, ctx, out as never)
209
+ if (!cycled) tx.transferObjects([out as never], ctx.agentAddress)
210
+ }
196
211
  const transaction = tx.txBlock
197
212
 
198
213
  const sim = await simulate(ctx.client, transaction, ctx.agentAddress)
package/src/tools/send.ts CHANGED
@@ -15,6 +15,7 @@ import { checkMinimum } from '../minimums'
15
15
  import { evaluatePolicy, suiToMist } from '../policy'
16
16
  import { simulate } from '../simulate'
17
17
  import type { OnchainRuntimeContext } from '../types'
18
+ import { canFundFromVault } from '../vault-fund'
18
19
 
19
20
  const SUI_TYPE = '0x2::sui::SUI'
20
21
 
@@ -55,26 +56,51 @@ export function makeSuiSend(ctx: OnchainRuntimeContext): ToolDef<Args> {
55
56
  }
56
57
  }
57
58
 
58
- // 2. Build the PTB: split + transfer, plus on-chain receipt/enforcement.
59
+ // 2. Build the PTB.
59
60
  const tx = new Transaction()
60
- const [coin] = tx.splitCoins(tx.gas, [amountMist])
61
- tx.transferObjects([coin], to)
62
- const recordsOnChain = Boolean(ctx.packageId && ctx.policyObjectId)
63
- if (recordsOnChain) {
61
+ const vaultBacked = canFundFromVault(ctx, amountMist)
62
+ if (vaultBacked) {
63
+ // Vault-backed transfer: draw from the treasury via the full policy gate
64
+ // AND enforce the recipient allowlist ON-CHAIN (vault_transfer sends the
65
+ // funds to `to` and routes the ActionReceipt to the owner). A prompt-
66
+ // injected agent can't pay an un-allowlisted address even within budget.
64
67
  tx.moveCall({
65
- target: `${ctx.packageId}::policy::record_action`,
68
+ target: `${ctx.packageId}::vault::vault_transfer`,
66
69
  typeArguments: [SUI_TYPE],
67
70
  arguments: [
71
+ tx.object(ctx.vaultId as string),
68
72
  tx.object(ctx.policyObjectId as string),
69
73
  tx.pure.u64(amountMist),
70
- tx.pure.address('0x0'),
71
- tx.pure.vector('u8', Array.from(new TextEncoder().encode('transfer'))),
72
- tx.pure.vector('u8', Array.from(new TextEncoder().encode(`to ${to}`))),
74
+ tx.pure.address(to),
75
+ tx.pure.vector('u8', Array.from(new TextEncoder().encode('send transfer'))),
73
76
  tx.object.clock(),
74
77
  ],
75
78
  })
79
+ } else {
80
+ // Single-key mode (no vault wired): split from the agent's coin + transfer,
81
+ // plus an on-chain receipt when a policy object is configured.
82
+ const [coin] = tx.splitCoins(tx.gas, [amountMist])
83
+ tx.transferObjects([coin], to)
84
+ if (ctx.packageId && ctx.policyObjectId) {
85
+ tx.moveCall({
86
+ target: `${ctx.packageId}::policy::record_action`,
87
+ typeArguments: [SUI_TYPE],
88
+ arguments: [
89
+ tx.object(ctx.policyObjectId as string),
90
+ tx.pure.u64(amountMist),
91
+ tx.pure.address('0x0'),
92
+ tx.pure.vector('u8', Array.from(new TextEncoder().encode('transfer'))),
93
+ tx.pure.vector('u8', Array.from(new TextEncoder().encode(`to ${to}`))),
94
+ tx.object.clock(),
95
+ ],
96
+ })
97
+ }
76
98
  }
77
99
 
100
+ // Both paths enforce on-chain when configured: vault_transfer always mints
101
+ // a receipt; the gas path records only when a policy object is set.
102
+ const recordsOnChain = vaultBacked || Boolean(ctx.packageId && ctx.policyObjectId)
103
+
78
104
  // 3. Simulate-before-write.
79
105
  const sim = await simulate(ctx.client, tx, ctx.agentAddress)
80
106
  if (!sim.ok) {
@@ -15,8 +15,10 @@ import type { ToolDef } from 'lyra-core'
15
15
  import { z } from 'zod'
16
16
  import { checkMinimum } from '../minimums'
17
17
  import { evaluatePolicy, suiToMist } from '../policy'
18
+ import { PROTOCOL_IDS } from '../protocol-ids'
18
19
  import { simulate } from '../simulate'
19
20
  import type { OnchainRuntimeContext } from '../types'
21
+ import { fundSui } from '../vault-fund'
20
22
 
21
23
  const SUI_TYPE = '0x2::sui::SUI'
22
24
  const SUI_SYSTEM_STATE = '0x5'
@@ -85,7 +87,13 @@ export function makeStake(ctx: OnchainRuntimeContext): ToolDef<StakeArgs> {
85
87
  if (!validator) return { ok: false, error: 'no active validator found to stake with' }
86
88
 
87
89
  const tx = new Transaction()
88
- const [coin] = tx.splitCoins(tx.gas, [amountMist])
90
+ // Source the stake from the treasury vault (policy-enforced) when one is
91
+ // wired; otherwise from the agent's gas coin (single-key mode).
92
+ const coin = fundSui(tx, ctx, amountMist, {
93
+ protocol: PROTOCOL_IDS.suiStaking,
94
+ kind: 'stake',
95
+ memo: `stake ${args.amount} SUI`,
96
+ })
89
97
  tx.moveCall({
90
98
  target: '0x3::sui_system::request_add_stake',
91
99
  arguments: [tx.object(SUI_SYSTEM_STATE), coin, tx.pure.address(validator.address)],
@@ -20,18 +20,22 @@
20
20
  */
21
21
 
22
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'
23
+ // Import from @suilend/sdk SUBPATHS, not the package index. The index re-exports
24
+ // strategies.js → @suilend/springsui-sdk, whose ESM does a directory import
25
+ // (`import './lib'`) that Node's ESM resolver rejects — which would break
26
+ // importing this plugin under Node (the Next.js web server; Bun tolerates it).
27
+ // The lending client + initialize subpaths don't pull springsui and load cleanly
28
+ // on both runtimes, so Suilend works on the web AND the CLI.
29
+ import { LENDING_MARKET_ID, LENDING_MARKET_TYPE, SuilendClient } from '@suilend/sdk/client'
30
+ import { initializeSuilend } from '@suilend/sdk/lib/initialize'
29
31
  import type { ToolDef } from 'lyra-core'
30
32
  import { z } from 'zod'
31
33
  import { checkMinimum } from '../minimums'
32
34
  import { evaluatePolicy, suiToMist } from '../policy'
35
+ import { PROTOCOL_IDS } from '../protocol-ids'
33
36
  import { simulate } from '../simulate'
34
37
  import type { OnchainRuntimeContext } from '../types'
38
+ import { fundSui } from '../vault-fund'
35
39
 
36
40
  const SUI_TYPE = '0x2::sui::SUI'
37
41
  // Canonical + long-form SUI coin types (reserve maps may use either).
@@ -156,7 +160,12 @@ export function makeSuilendSupply(ctx: OnchainRuntimeContext): ToolDef<AmountArg
156
160
  const existing = await findObligation(ctx)
157
161
  const tx = new Transaction()
158
162
  tx.setSender(ctx.agentAddress)
159
- const [coin] = tx.splitCoins(tx.gas, [amountMist])
163
+ // Source the supply from the treasury vault (policy-enforced) when wired.
164
+ const coin = fundSui(tx, ctx, amountMist, {
165
+ protocol: PROTOCOL_IDS.suilend,
166
+ kind: 'supply',
167
+ memo: 'suilend supply',
168
+ })
160
169
  // The @suilend/sdk@1.1.x SDK carries its own nested @mysten/sui copy, so
161
170
  // TS sees a distinct Transaction class — cast at the boundary. The two
162
171
  // copies interop at runtime (verified with a live mainnet dry-run).
package/src/tools/swap.ts CHANGED
@@ -17,6 +17,7 @@ import { checkMinimum } from '../minimums'
17
17
  import { evaluatePolicy } from '../policy'
18
18
  import { simulate } from '../simulate'
19
19
  import type { OnchainRuntimeContext } from '../types'
20
+ import { fundSui } from '../vault-fund'
20
21
 
21
22
  const SUI_TYPE = '0x2::sui::SUI'
22
23
 
@@ -119,9 +120,16 @@ export function makeSwap(ctx: OnchainRuntimeContext): ToolDef<Args> {
119
120
  tx.setSender(me)
120
121
  tx.setGasBudget(150_000_000)
121
122
  try {
123
+ // SUI input is sourced from the treasury vault (policy-enforced) when
124
+ // wired; non-SUI input comes from the agent's own balance (the vault
125
+ // is SUI-typed). The swapped output goes to the agent.
122
126
  const coinIn =
123
127
  from.type === SUI_TYPE
124
- ? tx.splitCoins(tx.gas, [amountIn])[0]
128
+ ? fundSui(tx, ctx, amountIn, {
129
+ protocol: '0x0',
130
+ kind: 'swap',
131
+ memo: `swap to ${args.to}`,
132
+ })
125
133
  : coinWithBalance({ type: from.type, balance: amountIn })
126
134
  const coinOut = await ag.swap({ quote: q, signer: me, tx, coinIn: coinIn as never })
127
135
  tx.transferObjects([coinOut as never], me)
package/src/types.ts CHANGED
@@ -23,6 +23,27 @@ export interface OnchainRuntimeContext {
23
23
  packageId?: string
24
24
  /** Shared `AgentPolicy` object id. When set, writes compose an on-chain receipt + enforcement. */
25
25
  policyObjectId?: string
26
+ /**
27
+ * Treasury `Vault<SUI>` object id bound to `policyObjectId`. When set (together
28
+ * with `policyObjectId` + `packageId`), write tools source their SUI from the
29
+ * vault via the policy-gated `vault_spend` instead of the agent's gas coin — so
30
+ * the vault is the single fund source and every deployment is enforced on-chain.
31
+ * When unset, tools fall back to the agent's own SUI (single-key mode).
32
+ */
33
+ vaultId?: string
34
+ /**
35
+ * The vault/policy owner address. Audit `ActionReceipt`s from `vault_spend` are
36
+ * sent here (falls back to the agent when unset). The owner also receives any
37
+ * funds swept back to the treasury.
38
+ */
39
+ ownerAddress?: string
40
+ /**
41
+ * Snapshot of the vault's SUI balance (MIST, as a string) at ctx-build time.
42
+ * A funding heuristic: when the vault can't cover an action, tools fall back to
43
+ * the agent's own SUI so the action still works (the on-chain `vault_spend` is
44
+ * the authoritative gate). Absent/`"0"` ⇒ always fall back.
45
+ */
46
+ vaultMist?: string
26
47
  /** Walrus network for receipt/memory storage (defaults to `network`). */
27
48
  walrusNetwork?: SuiNetwork
28
49
  agentDir: string
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Vault-funded actions. Lyra's treasury `lyra::vault::Vault<SUI>` is the SINGLE
3
+ * fund source for agent actions: instead of splitting the agent's own gas coin,
4
+ * a write tool draws SUI from the owner's vault via the policy-gated `vault_spend`
5
+ * (the agent signs; `enforce_spend` re-checks budget, per-tx cap, coin/protocol
6
+ * allowlists, expiry, and revoke ON-CHAIN). Idle funds stay in the vault, and the
7
+ * treasury is topped back up on withdrawal (Option 1: funds cycle vault→protocol→
8
+ * vault, so the treasury doesn't drain as the agent works).
9
+ *
10
+ * All helpers degrade gracefully: with no vault configured in the runtime ctx,
11
+ * `fundSui` falls back to the agent's gas coin — so single-key mode (e.g. the CLI
12
+ * with no provisioned vault) keeps working unchanged.
13
+ */
14
+
15
+ import type { Transaction, TransactionObjectArgument } from '@mysten/sui/transactions'
16
+ import type { OnchainRuntimeContext } from './types'
17
+
18
+ const SUI_TYPE = '0x2::sui::SUI'
19
+ const CLOCK = '0x6'
20
+
21
+ /** True when the ctx carries a fully-wired treasury vault (vault + policy + package). */
22
+ export function hasVault(ctx: OnchainRuntimeContext): boolean {
23
+ return Boolean(ctx.vaultId && ctx.policyObjectId && ctx.packageId)
24
+ }
25
+
26
+ /**
27
+ * True when the wired vault can cover `amountMist` (per its balance snapshot).
28
+ * When false, callers fund from the agent's own SUI so the action still works —
29
+ * e.g. the user provisioned a vault but funded the agent directly, or the vault
30
+ * is short for this action. The on-chain `vault_spend` remains the hard gate.
31
+ */
32
+ export function canFundFromVault(ctx: OnchainRuntimeContext, amountMist: bigint): boolean {
33
+ if (!hasVault(ctx)) return false
34
+ return BigInt(ctx.vaultMist ?? '0') >= amountMist
35
+ }
36
+
37
+ function enc(tx: Transaction, s: string) {
38
+ return tx.pure.vector('u8', Array.from(new TextEncoder().encode(s)))
39
+ }
40
+
41
+ export interface FundOpts {
42
+ /**
43
+ * Protocol package/registry id checked against the policy's protocol allowlist.
44
+ * Pass the protocol's package id (e.g. NAVI, Scallop) so an owner-set allowlist
45
+ * gates it meaningfully; `0x0` when the action isn't protocol-scoped (a plain
46
+ * transfer). Defaults to `0x0`.
47
+ */
48
+ protocol?: string
49
+ /** Short action kind for the on-chain receipt (e.g. `stake`, `supply`, `swap`). */
50
+ kind: string
51
+ /** Human memo for the on-chain receipt. */
52
+ memo: string
53
+ }
54
+
55
+ /**
56
+ * Source `amountMist` of SUI for an in-PTB action. When a vault is wired, draws
57
+ * from it via `vault_spend` (policy-enforced) and routes the audit `ActionReceipt`
58
+ * to the owner; otherwise splits the agent's gas coin. Returns the `Coin<SUI>` to
59
+ * feed into the protocol call in the SAME transaction.
60
+ */
61
+ export function fundSui(
62
+ tx: Transaction,
63
+ ctx: OnchainRuntimeContext,
64
+ amountMist: bigint,
65
+ opts: FundOpts,
66
+ ): TransactionObjectArgument {
67
+ if (canFundFromVault(ctx, amountMist)) {
68
+ const [coin, receipt] = tx.moveCall({
69
+ target: `${ctx.packageId}::vault::vault_spend`,
70
+ typeArguments: [SUI_TYPE],
71
+ arguments: [
72
+ tx.object(ctx.vaultId as string),
73
+ tx.object(ctx.policyObjectId as string),
74
+ tx.pure.u64(amountMist),
75
+ tx.pure.address(opts.protocol ?? '0x0'),
76
+ enc(tx, opts.kind),
77
+ enc(tx, opts.memo),
78
+ tx.object(CLOCK),
79
+ ],
80
+ })
81
+ // The receipt is an on-chain audit object (key+store); park it with the owner
82
+ // (or the agent when the owner is unknown) so the PTB has no dangling value.
83
+ tx.transferObjects([receipt as TransactionObjectArgument], ctx.ownerAddress ?? ctx.agentAddress)
84
+ return coin as TransactionObjectArgument
85
+ }
86
+ const [coin] = tx.splitCoins(tx.gas, [amountMist])
87
+ return coin as TransactionObjectArgument
88
+ }
89
+
90
+ /**
91
+ * Return a `Coin<SUI>` to the treasury vault (Option 1: withdrawals/unstakes cycle
92
+ * back into the vault instead of accumulating on the agent). No-op-returns `false`
93
+ * when no vault is wired, so the caller can transfer the coin to the agent instead.
94
+ */
95
+ export function returnSuiToVault(
96
+ tx: Transaction,
97
+ ctx: OnchainRuntimeContext,
98
+ coin: TransactionObjectArgument,
99
+ ): boolean {
100
+ if (!ctx.vaultId || !ctx.packageId) return false
101
+ tx.moveCall({
102
+ target: `${ctx.packageId}::vault::deposit`,
103
+ typeArguments: [SUI_TYPE],
104
+ arguments: [tx.object(ctx.vaultId), coin],
105
+ })
106
+ return true
107
+ }
package/src/vault.ts CHANGED
@@ -22,6 +22,70 @@ export interface OwnerVault {
22
22
  vaultMist: string
23
23
  }
24
24
 
25
+ export interface AgentVault {
26
+ policyId: string
27
+ vaultId: string
28
+ owner: string
29
+ vaultMist: string
30
+ }
31
+
32
+ /**
33
+ * Resolve the treasury vault for a delegated `agentAddress` WITHOUT knowing the
34
+ * owner — the CLI/gateway single-tenant path (the running agent knows only its own
35
+ * address). Finds the `AgentPolicy` delegating to this agent via `PolicyCreated`
36
+ * events, then its `Vault` via `VaultOpened`. Returns null when the agent has no
37
+ * (unrevoked) policy+vault, so the tools fall back to the agent's own SUI.
38
+ */
39
+ export async function resolveVaultForAgent(
40
+ agentAddress: string,
41
+ network: SuiNetwork = 'mainnet',
42
+ ): Promise<AgentVault | null> {
43
+ const client = makeSuiClient(network)
44
+ const created = await client
45
+ .queryEvents({
46
+ query: { MoveEventType: `${ORIGINAL_PKG}::policy::PolicyCreated` },
47
+ limit: 200,
48
+ order: 'descending',
49
+ })
50
+ .catch(() => null)
51
+ if (!created) return null
52
+
53
+ for (const e of created.data) {
54
+ // biome-ignore lint/suspicious/noExplicitAny: parsed Move event
55
+ const f = e.parsedJson as any
56
+ if (f?.agent !== agentAddress) continue
57
+ const policyId = f.policy_id as string | undefined
58
+ const owner = f.owner as string | undefined
59
+ if (!policyId || !owner) continue
60
+ const pol = await client
61
+ .getObject({ id: policyId, options: { showContent: true } })
62
+ .catch(() => null)
63
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic Move object fields
64
+ const pf = (pol?.data?.content as any)?.fields
65
+ if (!pf || pf.revoked) continue
66
+
67
+ const ev = await client
68
+ .queryEvents({
69
+ query: { MoveEventType: `${VAULT_PKG}::vault::VaultOpened` },
70
+ limit: 200,
71
+ order: 'descending',
72
+ })
73
+ .catch(() => null)
74
+ // biome-ignore lint/suspicious/noExplicitAny: parsed Move event
75
+ const match = ev?.data.find(x => (x.parsedJson as any)?.policy_id === policyId)
76
+ // biome-ignore lint/suspicious/noExplicitAny: parsed Move event
77
+ const vaultId = (match?.parsedJson as any)?.vault_id as string | undefined
78
+ if (!vaultId) continue
79
+ const v = await client
80
+ .getObject({ id: vaultId, options: { showContent: true } })
81
+ .catch(() => null)
82
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic Move object fields
83
+ const vaultMist = String((v?.data?.content as any)?.fields?.balance ?? '0')
84
+ return { policyId, vaultId, owner, vaultMist }
85
+ }
86
+ return null
87
+ }
88
+
25
89
  /** Find the owner's policy (delegating to their derived agent) + its SUI vault. */
26
90
  export async function resolveOwnerVault(
27
91
  owner: string,
@@ -1,45 +0,0 @@
1
- import { describe, expect, it } from 'bun:test'
2
- import { deriveAgentAddress, deriveAgentKeypair } from './derive'
3
-
4
- const MASTER = 'a'.repeat(64)
5
- const A = '0x46e37419c58981bb69100fa559baa52fd7e219486d75e9dc3f0896fab01d06f5'
6
- const B = '0x250880a4c1a268da8011b164f599d4e100cefce84f862d36396cd1a943ee8a35'
7
-
8
- describe('agent derivation', () => {
9
- it('is deterministic for the same owner + master', () => {
10
- expect(deriveAgentAddress(A, MASTER)).toBe(deriveAgentAddress(A, MASTER))
11
- })
12
-
13
- it('gives distinct agents for distinct owners', () => {
14
- expect(deriveAgentAddress(A, MASTER)).not.toBe(deriveAgentAddress(B, MASTER))
15
- })
16
-
17
- it('is case-insensitive on the owner address', () => {
18
- expect(deriveAgentAddress(A.toUpperCase().replace('0X', '0x'), MASTER)).toBe(
19
- deriveAgentAddress(A, MASTER),
20
- )
21
- })
22
-
23
- it('changes with the master secret (no master reuse leaks)', () => {
24
- expect(deriveAgentAddress(A, MASTER)).not.toBe(deriveAgentAddress(A, `${'b'.repeat(64)}`))
25
- })
26
-
27
- it('returns a valid 32-byte Sui address', () => {
28
- expect(deriveAgentAddress(A, MASTER)).toMatch(/^0x[0-9a-f]{64}$/)
29
- })
30
-
31
- it('the keypair signs (a real Ed25519 key)', async () => {
32
- const kp = deriveAgentKeypair(A, MASTER)
33
- const { signature } = await kp.signPersonalMessage(new TextEncoder().encode('hi'))
34
- expect(typeof signature).toBe('string')
35
- expect(signature.length).toBeGreaterThan(0)
36
- })
37
-
38
- it('rejects a too-short master secret', () => {
39
- expect(() => deriveAgentAddress(A, 'short')).toThrow()
40
- })
41
-
42
- it('rejects a malformed owner address', () => {
43
- expect(() => deriveAgentAddress('not-an-address', MASTER)).toThrow()
44
- })
45
- })
@@ -1,36 +0,0 @@
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
- })
@@ -1,187 +0,0 @@
1
- import { describe, expect, it } from 'bun:test'
2
- import {
3
- type SuiPolicy,
4
- type SuiPolicyAction,
5
- evaluatePolicy,
6
- normalizeCoinType,
7
- policyFromEnv,
8
- suiToMist,
9
- } from './policy'
10
-
11
- const ONE_SUI = 1_000_000_000n // 1 SUI in MIST
12
- const SUI = '0x2::sui::SUI'
13
-
14
- const send = (over: Partial<SuiPolicyAction> = {}): SuiPolicyAction => ({
15
- kind: 'transfer',
16
- coinType: SUI,
17
- amountMist: ONE_SUI,
18
- to: '0x1111111111111111111111111111111111111111111111111111111111111111',
19
- protocol: 'transfer',
20
- ...over,
21
- })
22
-
23
- describe('evaluatePolicy', () => {
24
- it('allows a compliant native transfer', () => {
25
- const v = evaluatePolicy(send(), { maxMistPerTx: 2n * ONE_SUI })
26
- expect(v.allowed).toBe(true)
27
- expect(v.violations).toHaveLength(0)
28
- expect(v.requiresApproval).toBe(false)
29
- })
30
-
31
- it('blocks a transfer over the per-tx cap', () => {
32
- const v = evaluatePolicy(send({ amountMist: 5n * ONE_SUI }), { maxMistPerTx: 2n * ONE_SUI })
33
- expect(v.allowed).toBe(false)
34
- expect(v.violations[0]).toContain('exceeds per-tx cap')
35
- })
36
-
37
- it('blocks a recipient not in the allowlist', () => {
38
- const v = evaluatePolicy(send({ to: '0x2222' }), { recipientAllowlist: ['0x1111'] })
39
- expect(v.allowed).toBe(false)
40
- expect(v.violations[0]).toContain('not in the recipient allowlist')
41
- })
42
-
43
- it('allows an allowlisted recipient case-insensitively', () => {
44
- const v = evaluatePolicy(send({ to: '0xABCD' }), { recipientAllowlist: ['0xabcd'] })
45
- expect(v.allowed).toBe(true)
46
- })
47
-
48
- it('blocks a coin not in the coin allowlist', () => {
49
- const v = evaluatePolicy(send({ coinType: '0xdead::x::X' }), { coinAllowlist: [SUI] })
50
- expect(v.allowed).toBe(false)
51
- expect(v.violations[0]).toContain('not in the coin allowlist')
52
- })
53
-
54
- it('allows SUI by short or padded form against an allowlist', () => {
55
- const padded = `0x${'0'.repeat(63)}2::sui::SUI`
56
- expect(evaluatePolicy(send({ coinType: padded }), { coinAllowlist: [SUI] }).allowed).toBe(true)
57
- expect(evaluatePolicy(send({ coinType: 'native' }), { coinAllowlist: [SUI] }).allowed).toBe(
58
- true,
59
- )
60
- })
61
-
62
- it('blocks a swap whose slippage exceeds the cap', () => {
63
- const v = evaluatePolicy(
64
- { kind: 'swap', coinType: SUI, amountMist: ONE_SUI, slippageBps: 300 },
65
- { maxSlippageBps: 100 },
66
- )
67
- expect(v.allowed).toBe(false)
68
- expect(v.violations[0]).toContain('slippage')
69
- })
70
-
71
- it('blocks a protocol not in the protocol allowlist', () => {
72
- const v = evaluatePolicy(send({ protocol: 'cetus' }), {
73
- protocolAllowlist: ['transfer', 'deepbook'],
74
- })
75
- expect(v.allowed).toBe(false)
76
- expect(v.violations[0]).toContain('not in the protocol allowlist')
77
- })
78
-
79
- it('blocks everything under a read-only policy', () => {
80
- expect(evaluatePolicy(send(), { readOnly: true }).allowed).toBe(false)
81
- expect(evaluatePolicy(send(), { autonomy: 'readonly' }).allowed).toBe(false)
82
- })
83
-
84
- it('blocks when the policy has expired', () => {
85
- const v = evaluatePolicy(send(), { expiryMs: 1_000 }, 2_000)
86
- expect(v.allowed).toBe(false)
87
- expect(v.violations[0]).toContain('expired')
88
- })
89
-
90
- it('allows before the policy expires', () => {
91
- expect(evaluatePolicy(send(), { expiryMs: 5_000 }, 2_000).allowed).toBe(true)
92
- })
93
-
94
- it('requires approval in the confirm tier', () => {
95
- const v = evaluatePolicy(send(), { autonomy: 'confirm' })
96
- expect(v.allowed).toBe(true)
97
- expect(v.requiresApproval).toBe(true)
98
- })
99
-
100
- it('escalates to approval when a spend exceeds the auto ceiling', () => {
101
- const policy: SuiPolicy = { autonomy: 'auto', autoMaxMistPerTx: ONE_SUI / 10n }
102
- expect(evaluatePolicy(send({ amountMist: ONE_SUI / 100n }), policy).requiresApproval).toBe(
103
- false,
104
- )
105
- expect(evaluatePolicy(send({ amountMist: ONE_SUI }), policy).requiresApproval).toBe(true)
106
- })
107
- })
108
-
109
- describe('coin allowlist — adversarial', () => {
110
- const A = '0xaaa::coin::A'
111
- const B = '0xbbb::coin::B'
112
- const policy: SuiPolicy = { coinAllowlist: [A] }
113
-
114
- it('blocks a swap whose OUTPUT coin is not allowlisted', () => {
115
- const v = evaluatePolicy({ kind: 'swap', coinType: A, toCoinType: B, amountMist: 1n }, policy)
116
- expect(v.allowed).toBe(false)
117
- expect(v.violations.some(s => /output coin/.test(s))).toBe(true)
118
- })
119
-
120
- it('allows a swap when both legs are allowlisted', () => {
121
- const v = evaluatePolicy({ kind: 'swap', coinType: A, toCoinType: A, amountMist: 1n }, policy)
122
- expect(v.allowed).toBe(true)
123
- })
124
-
125
- it('still blocks a swap whose INPUT coin is not allowlisted', () => {
126
- const v = evaluatePolicy({ kind: 'swap', coinType: B, toCoinType: A, amountMist: 1n }, policy)
127
- expect(v.allowed).toBe(false)
128
- })
129
- })
130
-
131
- describe('amount-cap boundaries', () => {
132
- it('allows exactly at the cap, blocks one MIST over', () => {
133
- const policy: SuiPolicy = { maxMistPerTx: ONE_SUI }
134
- expect(evaluatePolicy(send({ amountMist: ONE_SUI }), policy).allowed).toBe(true)
135
- expect(evaluatePolicy(send({ amountMist: ONE_SUI + 1n }), policy).allowed).toBe(false)
136
- })
137
-
138
- it('auto tier: no approval exactly at the auto ceiling, approval one MIST over', () => {
139
- const policy: SuiPolicy = { autoMaxMistPerTx: ONE_SUI }
140
- expect(evaluatePolicy(send({ amountMist: ONE_SUI }), policy).requiresApproval).toBe(false)
141
- expect(evaluatePolicy(send({ amountMist: ONE_SUI + 1n }), policy).requiresApproval).toBe(true)
142
- })
143
- })
144
-
145
- describe('suiToMist + normalizeCoinType', () => {
146
- it('converts decimal SUI to MIST', () => {
147
- expect(suiToMist('1')).toBe(ONE_SUI)
148
- expect(suiToMist('0.1')).toBe(ONE_SUI / 10n)
149
- expect(suiToMist('1.5')).toBe(1_500_000_000n)
150
- expect(suiToMist('')).toBeUndefined()
151
- expect(suiToMist('-1')).toBeUndefined()
152
- })
153
-
154
- it('canonicalizes SUI forms and aliases', () => {
155
- expect(normalizeCoinType('SUI')).toBe('0x2::sui::sui')
156
- expect(normalizeCoinType('native')).toBe('0x2::sui::sui')
157
- expect(normalizeCoinType(`0x${'0'.repeat(63)}2::sui::SUI`)).toBe('0x2::sui::sui')
158
- })
159
- })
160
-
161
- describe('policyFromEnv', () => {
162
- it('returns undefined when no policy env is set', () => {
163
- expect(policyFromEnv({})).toBeUndefined()
164
- })
165
-
166
- it('parses caps, slippage, tier, allowlists and expiry', () => {
167
- const p = policyFromEnv(
168
- {
169
- LYRA_POLICY_MAX_PER_TX_SUI: '1.0',
170
- LYRA_POLICY_AUTO_MAX_SUI: '0.1',
171
- LYRA_POLICY_MAX_SLIPPAGE_BPS: '100',
172
- LYRA_POLICY_AUTONOMY: 'auto',
173
- LYRA_POLICY_ALLOWED_PROTOCOLS: 'transfer, deepbook, walrus',
174
- LYRA_POLICY_ALLOWED_COINS: '0x2::sui::SUI',
175
- LYRA_POLICY_EXPIRY_MINUTES: '60',
176
- },
177
- 1_000_000,
178
- )
179
- expect(p?.maxMistPerTx).toBe(ONE_SUI)
180
- expect(p?.autoMaxMistPerTx).toBe(ONE_SUI / 10n)
181
- expect(p?.maxSlippageBps).toBe(100)
182
- expect(p?.autonomy).toBe('auto')
183
- expect(p?.protocolAllowlist).toEqual(['transfer', 'deepbook', 'walrus'])
184
- expect(p?.coinAllowlist).toEqual(['0x2::sui::SUI'])
185
- expect(p?.expiryMs).toBe(1_000_000 + 60 * 60_000)
186
- })
187
- })
@@ -1,111 +0,0 @@
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
- })
@@ -1,140 +0,0 @@
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
- })