lyra-plugin-onchain 0.1.8 → 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.8",
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/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'
@@ -76,9 +74,21 @@ export {
76
74
  } from './policy'
77
75
  export { policyRequiresApprovalForCall } from './approval'
78
76
  export { deriveAgentKeypair, deriveAgentAddress } from './derive'
79
- export { resolveOwnerVault, type OwnerVault } from './vault'
77
+ export {
78
+ resolveOwnerVault,
79
+ resolveVaultForAgent,
80
+ type OwnerVault,
81
+ type AgentVault,
82
+ } from './vault'
80
83
  export { ONCHAIN_GUIDANCE } from './guidance'
81
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'
82
92
 
83
93
  const plugin: NativePlugin = {
84
94
  name: 'onchain',
@@ -105,8 +115,6 @@ const plugin: NativePlugin = {
105
115
  ctx.registerTool(makeScallopPosition(onchain) as ToolDef)
106
116
  ctx.registerTool(makeScallopSupply(onchain) as ToolDef)
107
117
  ctx.registerTool(makeScallopWithdraw(onchain) as ToolDef)
108
- ctx.registerTool(makeScallopBorrow(onchain) as ToolDef)
109
- ctx.registerTool(makeScallopRepay(onchain) as ToolDef)
110
118
  ctx.registerTool(makeNaviMarkets(onchain) as ToolDef)
111
119
  ctx.registerTool(makeNaviPosition(onchain) as ToolDef)
112
120
  ctx.registerTool(makeNaviSupply(onchain) as ToolDef)
@@ -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
+ ]
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',
@@ -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
 
@@ -129,32 +131,30 @@ const AmountSchema = z.object({
129
131
  })
130
132
  type AmountArgs = z.infer<typeof AmountSchema>
131
133
 
134
+ // Scallop redeems a MARKET-COIN amount on withdraw (not underlying SUI). We
135
+ // find the agent's SUI market coin by type pattern (robust to package upgrades).
136
+ const MARKET_COIN_SUI_RE = /::reserve::MarketCoin<0x0*2::sui::SUI>$/
137
+
132
138
  async function runScallopWrite(
133
139
  ctx: OnchainRuntimeContext,
134
140
  amount: string,
135
- kind: 'supply' | 'withdraw' | 'borrow' | 'repay',
141
+ kind: 'supply' | 'withdraw',
136
142
  ): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> {
137
143
  const err = ensureMainnet(ctx)
138
144
  if (err) return { ok: false, error: err }
139
145
  const amountMist = suiToMist(amount)
140
146
  if (amountMist === undefined || amountMist <= 0n)
141
147
  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)
148
+ // Minimum guard on supply (withdraw pulls the agent's own funds back).
149
+ if (kind === 'supply') {
150
+ const tooSmall = checkMinimum('supply', amountMist)
146
151
  if (tooSmall) return { ok: false, error: tooSmall }
147
152
  }
148
153
 
149
- // Policy gate on value-moving actions (borrow creates debt + hands out funds).
150
- if (ctx.policy && kind !== 'withdraw') {
154
+ // Policy gate on the value-moving supply.
155
+ if (ctx.policy && kind === 'supply') {
151
156
  const verdict = evaluatePolicy(
152
- {
153
- kind: 'transfer',
154
- coinType: SUI_TYPE,
155
- amountMist,
156
- protocol: kind === 'borrow' ? 'borrow' : 'scallop',
157
- },
157
+ { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'scallop' },
158
158
  ctx.policy,
159
159
  )
160
160
  if (!verdict.allowed)
@@ -166,16 +166,48 @@ async function runScallopWrite(
166
166
  const builder = await sdk.createScallopBuilder()
167
167
  const tx = builder.createTxBlock()
168
168
  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").
173
169
  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')
178
- if (out) tx.transferObjects([out as never], ctx.agentAddress)
170
+ if (kind === 'supply') {
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')
181
+ } else {
182
+ // withdrawQuick redeems a MARKET-COIN amount, not underlying SUI. Convert
183
+ // the requested SUI to market coins at the position's rate and clamp to the
184
+ // held balance, so a full/over-withdraw cleanly redeems everything.
185
+ const [balances, q] = await Promise.all([
186
+ ctx.client.getAllBalances({ owner: ctx.agentAddress }),
187
+ sdk.createScallopQuery().then(async query => {
188
+ await query.init()
189
+ return query
190
+ }),
191
+ ])
192
+ const mc = balances.find(b => MARKET_COIN_SUI_RE.test(b.coinType))
193
+ const heldMc = BigInt(mc?.totalBalance ?? '0')
194
+ if (heldMc <= 0n) return { ok: false, error: 'no Scallop SUI position to withdraw' }
195
+ const port = (await q.getUserPortfolio({ walletAddress: ctx.agentAddress })) as {
196
+ lendings?: Array<{ coinName?: string; suppliedCoin?: number }>
197
+ }
198
+ const supplied = (port.lendings ?? []).find(l => l.coinName === 'sui')?.suppliedCoin ?? 0
199
+ const suppliedMist = BigInt(Math.floor(supplied * 1e9))
200
+ // redeem = requested/rate, where rate = supplied/heldMc; clamp to heldMc.
201
+ let redeemMc = suppliedMist > 0n ? (amountMist * heldMc) / suppliedMist : heldMc
202
+ if (redeemMc > heldMc || redeemMc <= 0n) redeemMc = heldMc
203
+ out = await tx.withdrawQuick(Number(redeemMc), 'sui')
204
+ }
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
+ }
179
211
  const transaction = tx.txBlock
180
212
 
181
213
  const sim = await simulate(ctx.client, transaction, ctx.agentAddress)
@@ -229,23 +261,8 @@ export function makeScallopWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountA
229
261
  }
230
262
  }
231
263
 
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
- }
264
+ // NOTE: Scallop borrow/repay require opening a Scallop obligation + posting
265
+ // collateral (a separate flow from lending deposits — borrowQuick alone aborts
266
+ // "No obligation found"). Until that flow is wired, borrowing routes through the
267
+ // verified NAVI/Suilend adapters instead, so we don't ship a borrow tool here
268
+ // that can't execute.
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)],