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.
@@ -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).
@@ -57,19 +61,31 @@ function isSuiType(t: string): boolean {
57
61
  return t === SUI_TYPE || t === SUI_LONG
58
62
  }
59
63
 
60
- /** The agent's obligation cap + id, or null if it has no Suilend position yet. */
64
+ /** The agent's obligation cap + id, or null if it has no Suilend position yet.
65
+ * Retries: getObligationOwnerCaps reads owned objects + their BCS, which some
66
+ * fullnode replicas serve inconsistently right after a write ("invalid data
67
+ * type" / stale owned-object index) — a short retry rides out that lag. */
61
68
  async function findObligation(
62
69
  ctx: OnchainRuntimeContext,
63
70
  ): Promise<{ capId: string; obligationId: string } | null> {
64
- const caps = await SuilendClient.getObligationOwnerCaps(
65
- ctx.agentAddress,
66
- [LENDING_MARKET_TYPE],
67
- ctx.client as never,
68
- )
69
- if (!caps.length) return null
70
- const c = caps[0] as { id: unknown; obligationId: string | { id: string } }
71
- const obligationId = typeof c.obligationId === 'string' ? c.obligationId : c.obligationId.id
72
- return { capId: capObjectId(c), obligationId }
71
+ let lastErr: unknown
72
+ for (let attempt = 0; attempt < 4; attempt++) {
73
+ try {
74
+ const caps = await SuilendClient.getObligationOwnerCaps(
75
+ ctx.agentAddress,
76
+ [LENDING_MARKET_TYPE],
77
+ ctx.client as never,
78
+ )
79
+ if (!caps.length) return null
80
+ const c = caps[0] as { id: unknown; obligationId: string | { id: string } }
81
+ const obligationId = typeof c.obligationId === 'string' ? c.obligationId : c.obligationId.id
82
+ return { capId: capObjectId(c), obligationId }
83
+ } catch (e) {
84
+ lastErr = e
85
+ await new Promise(r => setTimeout(r, 1200))
86
+ }
87
+ }
88
+ throw lastErr
73
89
  }
74
90
 
75
91
  // --- suilend.supply --------------------------------------------------------
@@ -79,6 +95,43 @@ const AmountSchema = z.object({
79
95
  })
80
96
  type AmountArgs = z.infer<typeof AmountSchema>
81
97
 
98
+ // Borrowable/repayable assets. Suilend (like most money markets) forbids
99
+ // borrowing the SAME asset you post as collateral (obligation::borrow abort 8),
100
+ // so the canonical flow is "supply SUI → borrow a stablecoin". USDC is the
101
+ // default; SUI is available for the reverse (supply a stable, borrow SUI).
102
+ const USDC_TYPE = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
103
+ type CoinInfo = { type: string; decimals: number; minBase: bigint; label: string }
104
+ const BORROW_COINS: Record<'usdc' | 'sui', CoinInfo> = {
105
+ usdc: { type: USDC_TYPE, decimals: 6, minBase: 100_000n, label: 'USDC' }, // 0.1 USDC
106
+ sui: { type: SUI_TYPE, decimals: 9, minBase: 10_000_000n, label: 'SUI' }, // 0.01 SUI
107
+ }
108
+
109
+ /** Parse a decimal amount into base units for a given decimals count. */
110
+ function toBaseUnits(amount: string, decimals: number): bigint | undefined {
111
+ const a = amount.trim()
112
+ if (!/^\d+(\.\d+)?$/.test(a)) return undefined
113
+ const dot = a.indexOf('.')
114
+ const whole = dot === -1 ? a : a.slice(0, dot)
115
+ const frac = dot === -1 ? '' : a.slice(dot + 1)
116
+ const fracPadded = (frac + '0'.repeat(decimals)).slice(0, decimals)
117
+ try {
118
+ return BigInt(whole || '0') * 10n ** BigInt(decimals) + BigInt(fracPadded || '0')
119
+ } catch {
120
+ return undefined
121
+ }
122
+ }
123
+
124
+ const BorrowSchema = z.object({
125
+ amount: z.string().min(1).describe('Amount to borrow/repay, e.g. "5".'),
126
+ coin: z
127
+ .enum(['usdc', 'sui'])
128
+ .optional()
129
+ .describe(
130
+ 'Asset to borrow/repay (default "usdc"). Suilend disallows borrowing the same asset you supply as collateral, so borrowing USDC against SUI collateral is the canonical flow.',
131
+ ),
132
+ })
133
+ type BorrowArgs = z.infer<typeof BorrowSchema>
134
+
82
135
  export function makeSuilendSupply(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
83
136
  return {
84
137
  name: 'suilend.supply',
@@ -107,7 +160,12 @@ export function makeSuilendSupply(ctx: OnchainRuntimeContext): ToolDef<AmountArg
107
160
  const existing = await findObligation(ctx)
108
161
  const tx = new Transaction()
109
162
  tx.setSender(ctx.agentAddress)
110
- 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
+ })
111
169
  // The @suilend/sdk@1.1.x SDK carries its own nested @mysten/sui copy, so
112
170
  // TS sees a distinct Transaction class — cast at the boundary. The two
113
171
  // copies interop at runtime (verified with a live mainnet dry-run).
@@ -217,24 +275,25 @@ export function makeSuilendWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountA
217
275
 
218
276
  // --- suilend.borrow --------------------------------------------------------
219
277
 
220
- export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
278
+ export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<BorrowArgs> {
221
279
  return {
222
280
  name: 'suilend.borrow',
223
281
  description:
224
- 'Borrow SUI from Suilend against supplied collateral (amount in underlying SUI). Requires an existing obligation with enough health; the pre-flight simulation fails cleanly if under-collateralized. Policy-checked, simulated, then executed.',
225
- searchHint: 'suilend borrow loan leverage debt against collateral sui money market',
226
- schema: AmountSchema,
282
+ 'Borrow an asset from Suilend against supplied collateral. Default borrows USDC against SUI collateral (Suilend disallows same-asset borrows). Requires an existing obligation with enough health; the pre-flight simulation fails cleanly if under-collateralized. Policy-checked, simulated, then executed.',
283
+ searchHint: 'suilend borrow loan leverage debt against collateral usdc stablecoin money market',
284
+ schema: BorrowSchema,
227
285
  handler: async args => {
228
286
  const err = ensureMainnet(ctx)
229
287
  if (err) return { ok: false, error: err }
230
- const amountMist = suiToMist(args.amount)
231
- if (amountMist === undefined || amountMist <= 0n)
288
+ const coin = BORROW_COINS[args.coin ?? 'usdc']
289
+ const amountBase = toBaseUnits(args.amount, coin.decimals)
290
+ if (amountBase === undefined || amountBase <= 0n)
232
291
  return { ok: false, error: `invalid amount "${args.amount}"` }
233
- const tooSmall = checkMinimum('borrow', amountMist)
234
- if (tooSmall) return { ok: false, error: tooSmall }
292
+ if (amountBase < coin.minBase)
293
+ return { ok: false, error: `amount too small: below the minimum ${coin.label} borrow` }
235
294
  if (ctx.policy) {
236
295
  const verdict = evaluatePolicy(
237
- { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'borrow' },
296
+ { kind: 'transfer', coinType: coin.type, amountMist: amountBase, protocol: 'borrow' },
238
297
  ctx.policy,
239
298
  )
240
299
  if (!verdict.allowed)
@@ -252,8 +311,8 @@ export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArg
252
311
  ctx.agentAddress,
253
312
  obligation.capId,
254
313
  obligation.obligationId,
255
- SUI_TYPE,
256
- amountMist.toString(),
314
+ coin.type,
315
+ amountBase.toString(),
257
316
  tx as never,
258
317
  )
259
318
  const sim = await simulate(ctx.client, tx, ctx.agentAddress)
@@ -274,7 +333,8 @@ export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArg
274
333
  data: {
275
334
  protocol: 'suilend',
276
335
  action: 'borrow',
277
- amountSui: args.amount,
336
+ amount: args.amount,
337
+ coin: coin.label,
278
338
  digest: res.digest,
279
339
  policyEnforced: ctx.policy != null,
280
340
  },
@@ -288,22 +348,23 @@ export function makeSuilendBorrow(ctx: OnchainRuntimeContext): ToolDef<AmountArg
288
348
 
289
349
  // --- suilend.repay ---------------------------------------------------------
290
350
 
291
- export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
351
+ export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<BorrowArgs> {
292
352
  return {
293
353
  name: 'suilend.repay',
294
354
  description:
295
- 'Repay borrowed SUI debt on Suilend (amount in underlying SUI). Simulated, then executed.',
296
- searchHint: 'suilend repay pay back debt loan close sui money market',
297
- schema: AmountSchema,
355
+ 'Repay borrowed debt on Suilend (default USDC; pass coin "sui" to repay a SUI loan). Simulated, then executed.',
356
+ searchHint: 'suilend repay pay back debt loan close usdc stablecoin sui money market',
357
+ schema: BorrowSchema,
298
358
  handler: async args => {
299
359
  const err = ensureMainnet(ctx)
300
360
  if (err) return { ok: false, error: err }
301
- const amountMist = suiToMist(args.amount)
302
- if (amountMist === undefined || amountMist <= 0n)
361
+ const coin = BORROW_COINS[args.coin ?? 'usdc']
362
+ const amountBase = toBaseUnits(args.amount, coin.decimals)
363
+ if (amountBase === undefined || amountBase <= 0n)
303
364
  return { ok: false, error: `invalid amount "${args.amount}"` }
304
365
  if (ctx.policy) {
305
366
  const verdict = evaluatePolicy(
306
- { kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'suilend' },
367
+ { kind: 'transfer', coinType: coin.type, amountMist: amountBase, protocol: 'suilend' },
307
368
  ctx.policy,
308
369
  )
309
370
  if (!verdict.allowed)
@@ -318,8 +379,8 @@ export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs
318
379
  await suilend.repayIntoObligation(
319
380
  ctx.agentAddress,
320
381
  obligation.obligationId,
321
- SUI_TYPE,
322
- amountMist.toString(),
382
+ coin.type,
383
+ amountBase.toString(),
323
384
  tx as never,
324
385
  )
325
386
  const sim = await simulate(ctx.client, tx, ctx.agentAddress)
@@ -340,7 +401,8 @@ export function makeSuilendRepay(ctx: OnchainRuntimeContext): ToolDef<AmountArgs
340
401
  data: {
341
402
  protocol: 'suilend',
342
403
  action: 'repay',
343
- amountSui: args.amount,
404
+ amount: args.amount,
405
+ coin: coin.label,
344
406
  digest: res.digest,
345
407
  },
346
408
  }
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
- })