lyra-plugin-onchain 0.1.9 → 0.1.11
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 +18 -4
- package/src/index.ts +13 -1
- package/src/protocol-ids.ts +48 -0
- package/src/tools/liquid-stake.ts +8 -1
- package/src/tools/navi.ts +22 -7
- package/src/tools/scallop.ts +18 -3
- package/src/tools/send.ts +36 -10
- package/src/tools/stake.ts +9 -1
- package/src/tools/suilend.ts +16 -7
- package/src/tools/swap.ts +9 -1
- package/src/types.ts +21 -0
- package/src/vault-fund.ts +107 -0
- package/src/vault.ts +71 -4
- package/src/derive.test.ts +0 -45
- package/src/minimums.test.ts +0 -36
- package/src/policy.test.ts +0 -187
- package/src/tools/onchain.integration.test.ts +0 -111
- package/src/tools/tools.test.ts +0 -140
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lyra-plugin-onchain",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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",
|
|
@@ -13,14 +13,28 @@
|
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/rifkyeasy/lyra/issues"
|
|
15
15
|
},
|
|
16
|
-
"keywords": [
|
|
16
|
+
"keywords": [
|
|
17
|
+
"lyra",
|
|
18
|
+
"ai",
|
|
19
|
+
"agent",
|
|
20
|
+
"sui",
|
|
21
|
+
"defi",
|
|
22
|
+
"deepbook",
|
|
23
|
+
"walrus",
|
|
24
|
+
"policy",
|
|
25
|
+
"plugin"
|
|
26
|
+
],
|
|
17
27
|
"publishConfig": {
|
|
18
28
|
"access": "public"
|
|
19
29
|
},
|
|
20
30
|
"engines": {
|
|
21
31
|
"bun": ">=1.1"
|
|
22
32
|
},
|
|
23
|
-
"files": [
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"!src/**/*.test.ts",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
24
38
|
"main": "./src/index.ts",
|
|
25
39
|
"types": "./src/index.ts",
|
|
26
40
|
"scripts": {
|
|
@@ -37,7 +51,7 @@
|
|
|
37
51
|
"@scallop-io/sui-scallop-sdk": "^2.4.5",
|
|
38
52
|
"@suilend/sdk": "1.1.99",
|
|
39
53
|
"@suilend/sui-fe": "0.3.49",
|
|
40
|
-
"lyra-core": "^0.1.
|
|
54
|
+
"lyra-core": "^0.1.11",
|
|
41
55
|
"navi-sdk": "^1.7.3",
|
|
42
56
|
"zod": "^3.23.8"
|
|
43
57
|
}
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
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;
|
|
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
|
|
165
|
+
if (!returnSuiToVault(tx, ctx, coin as never))
|
|
166
|
+
tx.transferObjects([coin as never], ctx.agentAddress)
|
|
156
167
|
} else {
|
|
157
|
-
// repay:
|
|
158
|
-
const
|
|
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
|
|
package/src/tools/scallop.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
59
|
+
// 2. Build the PTB.
|
|
59
60
|
const tx = new Transaction()
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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}::
|
|
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(
|
|
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) {
|
|
@@ -98,7 +124,7 @@ export function makeSuiSend(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
|
98
124
|
const receipt = res.objectChanges?.find(
|
|
99
125
|
c =>
|
|
100
126
|
c.type === 'created' &&
|
|
101
|
-
String((c as { objectType?: string }).objectType).endsWith('::
|
|
127
|
+
String((c as { objectType?: string }).objectType).endsWith('::receipt::ActionReceipt'),
|
|
102
128
|
) as { objectId?: string } | undefined
|
|
103
129
|
|
|
104
130
|
return {
|
package/src/tools/stake.ts
CHANGED
|
@@ -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
|
-
|
|
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)],
|
package/src/tools/suilend.ts
CHANGED
|
@@ -20,18 +20,22 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { Transaction } from '@mysten/sui/transactions'
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
@@ -9,10 +9,13 @@
|
|
|
9
9
|
import { type SuiNetwork, makeSuiClient } from './client'
|
|
10
10
|
import { deriveAgentAddress } from './derive'
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
// Types/events are addressed by the id of the package that DEFINES them. The v1
|
|
13
|
+
// package is a single fresh publish (policy, vault, receipt, allowlist, constants
|
|
14
|
+
// all shipped together), so every module shares one id today. These stay separate
|
|
15
|
+
// constants so a FUTURE upgrade can again split the LATEST (moveCall) id from a
|
|
16
|
+
// module's DEFINING id without touching call sites.
|
|
17
|
+
const ORIGINAL_PKG = '0x1925bced9aeb16ca8159be0a10d39a0778fe618404443a4b6149116ad9997617'
|
|
18
|
+
const VAULT_PKG = '0x1925bced9aeb16ca8159be0a10d39a0778fe618404443a4b6149116ad9997617'
|
|
16
19
|
|
|
17
20
|
export interface OwnerVault {
|
|
18
21
|
policyId: string
|
|
@@ -22,6 +25,70 @@ export interface OwnerVault {
|
|
|
22
25
|
vaultMist: string
|
|
23
26
|
}
|
|
24
27
|
|
|
28
|
+
export interface AgentVault {
|
|
29
|
+
policyId: string
|
|
30
|
+
vaultId: string
|
|
31
|
+
owner: string
|
|
32
|
+
vaultMist: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the treasury vault for a delegated `agentAddress` WITHOUT knowing the
|
|
37
|
+
* owner — the CLI/gateway single-tenant path (the running agent knows only its own
|
|
38
|
+
* address). Finds the `AgentPolicy` delegating to this agent via `PolicyCreated`
|
|
39
|
+
* events, then its `Vault` via `VaultOpened`. Returns null when the agent has no
|
|
40
|
+
* (unrevoked) policy+vault, so the tools fall back to the agent's own SUI.
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveVaultForAgent(
|
|
43
|
+
agentAddress: string,
|
|
44
|
+
network: SuiNetwork = 'mainnet',
|
|
45
|
+
): Promise<AgentVault | null> {
|
|
46
|
+
const client = makeSuiClient(network)
|
|
47
|
+
const created = await client
|
|
48
|
+
.queryEvents({
|
|
49
|
+
query: { MoveEventType: `${ORIGINAL_PKG}::policy::PolicyCreated` },
|
|
50
|
+
limit: 200,
|
|
51
|
+
order: 'descending',
|
|
52
|
+
})
|
|
53
|
+
.catch(() => null)
|
|
54
|
+
if (!created) return null
|
|
55
|
+
|
|
56
|
+
for (const e of created.data) {
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: parsed Move event
|
|
58
|
+
const f = e.parsedJson as any
|
|
59
|
+
if (f?.agent !== agentAddress) continue
|
|
60
|
+
const policyId = f.policy_id as string | undefined
|
|
61
|
+
const owner = f.owner as string | undefined
|
|
62
|
+
if (!policyId || !owner) continue
|
|
63
|
+
const pol = await client
|
|
64
|
+
.getObject({ id: policyId, options: { showContent: true } })
|
|
65
|
+
.catch(() => null)
|
|
66
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic Move object fields
|
|
67
|
+
const pf = (pol?.data?.content as any)?.fields
|
|
68
|
+
if (!pf || pf.revoked) continue
|
|
69
|
+
|
|
70
|
+
const ev = await client
|
|
71
|
+
.queryEvents({
|
|
72
|
+
query: { MoveEventType: `${VAULT_PKG}::vault::VaultOpened` },
|
|
73
|
+
limit: 200,
|
|
74
|
+
order: 'descending',
|
|
75
|
+
})
|
|
76
|
+
.catch(() => null)
|
|
77
|
+
// biome-ignore lint/suspicious/noExplicitAny: parsed Move event
|
|
78
|
+
const match = ev?.data.find(x => (x.parsedJson as any)?.policy_id === policyId)
|
|
79
|
+
// biome-ignore lint/suspicious/noExplicitAny: parsed Move event
|
|
80
|
+
const vaultId = (match?.parsedJson as any)?.vault_id as string | undefined
|
|
81
|
+
if (!vaultId) continue
|
|
82
|
+
const v = await client
|
|
83
|
+
.getObject({ id: vaultId, options: { showContent: true } })
|
|
84
|
+
.catch(() => null)
|
|
85
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic Move object fields
|
|
86
|
+
const vaultMist = String((v?.data?.content as any)?.fields?.balance ?? '0')
|
|
87
|
+
return { policyId, vaultId, owner, vaultMist }
|
|
88
|
+
}
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
25
92
|
/** Find the owner's policy (delegating to their derived agent) + its SUI vault. */
|
|
26
93
|
export async function resolveOwnerVault(
|
|
27
94
|
owner: string,
|
package/src/derive.test.ts
DELETED
|
@@ -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
|
-
})
|
package/src/minimums.test.ts
DELETED
|
@@ -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
|
-
})
|
package/src/policy.test.ts
DELETED
|
@@ -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
|
-
})
|
package/src/tools/tools.test.ts
DELETED
|
@@ -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
|
-
})
|