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.
@@ -1,187 +0,0 @@
1
- import { describe, expect, it } from 'bun:test'
2
- import {
3
- type SuiPolicy,
4
- type SuiPolicyAction,
5
- evaluatePolicy,
6
- normalizeCoinType,
7
- policyFromEnv,
8
- suiToMist,
9
- } from './policy'
10
-
11
- const ONE_SUI = 1_000_000_000n // 1 SUI in MIST
12
- const SUI = '0x2::sui::SUI'
13
-
14
- const send = (over: Partial<SuiPolicyAction> = {}): SuiPolicyAction => ({
15
- kind: 'transfer',
16
- coinType: SUI,
17
- amountMist: ONE_SUI,
18
- to: '0x1111111111111111111111111111111111111111111111111111111111111111',
19
- protocol: 'transfer',
20
- ...over,
21
- })
22
-
23
- describe('evaluatePolicy', () => {
24
- it('allows a compliant native transfer', () => {
25
- const v = evaluatePolicy(send(), { maxMistPerTx: 2n * ONE_SUI })
26
- expect(v.allowed).toBe(true)
27
- expect(v.violations).toHaveLength(0)
28
- expect(v.requiresApproval).toBe(false)
29
- })
30
-
31
- it('blocks a transfer over the per-tx cap', () => {
32
- const v = evaluatePolicy(send({ amountMist: 5n * ONE_SUI }), { maxMistPerTx: 2n * ONE_SUI })
33
- expect(v.allowed).toBe(false)
34
- expect(v.violations[0]).toContain('exceeds per-tx cap')
35
- })
36
-
37
- it('blocks a recipient not in the allowlist', () => {
38
- const v = evaluatePolicy(send({ to: '0x2222' }), { recipientAllowlist: ['0x1111'] })
39
- expect(v.allowed).toBe(false)
40
- expect(v.violations[0]).toContain('not in the recipient allowlist')
41
- })
42
-
43
- it('allows an allowlisted recipient case-insensitively', () => {
44
- const v = evaluatePolicy(send({ to: '0xABCD' }), { recipientAllowlist: ['0xabcd'] })
45
- expect(v.allowed).toBe(true)
46
- })
47
-
48
- it('blocks a coin not in the coin allowlist', () => {
49
- const v = evaluatePolicy(send({ coinType: '0xdead::x::X' }), { coinAllowlist: [SUI] })
50
- expect(v.allowed).toBe(false)
51
- expect(v.violations[0]).toContain('not in the coin allowlist')
52
- })
53
-
54
- it('allows SUI by short or padded form against an allowlist', () => {
55
- const padded = `0x${'0'.repeat(63)}2::sui::SUI`
56
- expect(evaluatePolicy(send({ coinType: padded }), { coinAllowlist: [SUI] }).allowed).toBe(true)
57
- expect(evaluatePolicy(send({ coinType: 'native' }), { coinAllowlist: [SUI] }).allowed).toBe(
58
- true,
59
- )
60
- })
61
-
62
- it('blocks a swap whose slippage exceeds the cap', () => {
63
- const v = evaluatePolicy(
64
- { kind: 'swap', coinType: SUI, amountMist: ONE_SUI, slippageBps: 300 },
65
- { maxSlippageBps: 100 },
66
- )
67
- expect(v.allowed).toBe(false)
68
- expect(v.violations[0]).toContain('slippage')
69
- })
70
-
71
- it('blocks a protocol not in the protocol allowlist', () => {
72
- const v = evaluatePolicy(send({ protocol: 'cetus' }), {
73
- protocolAllowlist: ['transfer', 'deepbook'],
74
- })
75
- expect(v.allowed).toBe(false)
76
- expect(v.violations[0]).toContain('not in the protocol allowlist')
77
- })
78
-
79
- it('blocks everything under a read-only policy', () => {
80
- expect(evaluatePolicy(send(), { readOnly: true }).allowed).toBe(false)
81
- expect(evaluatePolicy(send(), { autonomy: 'readonly' }).allowed).toBe(false)
82
- })
83
-
84
- it('blocks when the policy has expired', () => {
85
- const v = evaluatePolicy(send(), { expiryMs: 1_000 }, 2_000)
86
- expect(v.allowed).toBe(false)
87
- expect(v.violations[0]).toContain('expired')
88
- })
89
-
90
- it('allows before the policy expires', () => {
91
- expect(evaluatePolicy(send(), { expiryMs: 5_000 }, 2_000).allowed).toBe(true)
92
- })
93
-
94
- it('requires approval in the confirm tier', () => {
95
- const v = evaluatePolicy(send(), { autonomy: 'confirm' })
96
- expect(v.allowed).toBe(true)
97
- expect(v.requiresApproval).toBe(true)
98
- })
99
-
100
- it('escalates to approval when a spend exceeds the auto ceiling', () => {
101
- const policy: SuiPolicy = { autonomy: 'auto', autoMaxMistPerTx: ONE_SUI / 10n }
102
- expect(evaluatePolicy(send({ amountMist: ONE_SUI / 100n }), policy).requiresApproval).toBe(
103
- false,
104
- )
105
- expect(evaluatePolicy(send({ amountMist: ONE_SUI }), policy).requiresApproval).toBe(true)
106
- })
107
- })
108
-
109
- describe('coin allowlist — adversarial', () => {
110
- const A = '0xaaa::coin::A'
111
- const B = '0xbbb::coin::B'
112
- const policy: SuiPolicy = { coinAllowlist: [A] }
113
-
114
- it('blocks a swap whose OUTPUT coin is not allowlisted', () => {
115
- const v = evaluatePolicy({ kind: 'swap', coinType: A, toCoinType: B, amountMist: 1n }, policy)
116
- expect(v.allowed).toBe(false)
117
- expect(v.violations.some(s => /output coin/.test(s))).toBe(true)
118
- })
119
-
120
- it('allows a swap when both legs are allowlisted', () => {
121
- const v = evaluatePolicy({ kind: 'swap', coinType: A, toCoinType: A, amountMist: 1n }, policy)
122
- expect(v.allowed).toBe(true)
123
- })
124
-
125
- it('still blocks a swap whose INPUT coin is not allowlisted', () => {
126
- const v = evaluatePolicy({ kind: 'swap', coinType: B, toCoinType: A, amountMist: 1n }, policy)
127
- expect(v.allowed).toBe(false)
128
- })
129
- })
130
-
131
- describe('amount-cap boundaries', () => {
132
- it('allows exactly at the cap, blocks one MIST over', () => {
133
- const policy: SuiPolicy = { maxMistPerTx: ONE_SUI }
134
- expect(evaluatePolicy(send({ amountMist: ONE_SUI }), policy).allowed).toBe(true)
135
- expect(evaluatePolicy(send({ amountMist: ONE_SUI + 1n }), policy).allowed).toBe(false)
136
- })
137
-
138
- it('auto tier: no approval exactly at the auto ceiling, approval one MIST over', () => {
139
- const policy: SuiPolicy = { autoMaxMistPerTx: ONE_SUI }
140
- expect(evaluatePolicy(send({ amountMist: ONE_SUI }), policy).requiresApproval).toBe(false)
141
- expect(evaluatePolicy(send({ amountMist: ONE_SUI + 1n }), policy).requiresApproval).toBe(true)
142
- })
143
- })
144
-
145
- describe('suiToMist + normalizeCoinType', () => {
146
- it('converts decimal SUI to MIST', () => {
147
- expect(suiToMist('1')).toBe(ONE_SUI)
148
- expect(suiToMist('0.1')).toBe(ONE_SUI / 10n)
149
- expect(suiToMist('1.5')).toBe(1_500_000_000n)
150
- expect(suiToMist('')).toBeUndefined()
151
- expect(suiToMist('-1')).toBeUndefined()
152
- })
153
-
154
- it('canonicalizes SUI forms and aliases', () => {
155
- expect(normalizeCoinType('SUI')).toBe('0x2::sui::sui')
156
- expect(normalizeCoinType('native')).toBe('0x2::sui::sui')
157
- expect(normalizeCoinType(`0x${'0'.repeat(63)}2::sui::SUI`)).toBe('0x2::sui::sui')
158
- })
159
- })
160
-
161
- describe('policyFromEnv', () => {
162
- it('returns undefined when no policy env is set', () => {
163
- expect(policyFromEnv({})).toBeUndefined()
164
- })
165
-
166
- it('parses caps, slippage, tier, allowlists and expiry', () => {
167
- const p = policyFromEnv(
168
- {
169
- LYRA_POLICY_MAX_PER_TX_SUI: '1.0',
170
- LYRA_POLICY_AUTO_MAX_SUI: '0.1',
171
- LYRA_POLICY_MAX_SLIPPAGE_BPS: '100',
172
- LYRA_POLICY_AUTONOMY: 'auto',
173
- LYRA_POLICY_ALLOWED_PROTOCOLS: 'transfer, deepbook, walrus',
174
- LYRA_POLICY_ALLOWED_COINS: '0x2::sui::SUI',
175
- LYRA_POLICY_EXPIRY_MINUTES: '60',
176
- },
177
- 1_000_000,
178
- )
179
- expect(p?.maxMistPerTx).toBe(ONE_SUI)
180
- expect(p?.autoMaxMistPerTx).toBe(ONE_SUI / 10n)
181
- expect(p?.maxSlippageBps).toBe(100)
182
- expect(p?.autonomy).toBe('auto')
183
- expect(p?.protocolAllowlist).toEqual(['transfer', 'deepbook', 'walrus'])
184
- expect(p?.coinAllowlist).toEqual(['0x2::sui::SUI'])
185
- expect(p?.expiryMs).toBe(1_000_000 + 60 * 60_000)
186
- })
187
- })
@@ -1,111 +0,0 @@
1
- /**
2
- * Integration coverage — LIVE mainnet dry-runs (devInspect, never broadcast).
3
- *
4
- * Gated: runs only when `LYRA_RUN_INTEGRATION=1` and a real `LYRA_AGENT_KEY` is
5
- * present. In CI (no secret) the whole suite is skipped, so it never flakes the
6
- * default `bun test`. Run locally with:
7
- *
8
- * LYRA_RUN_INTEGRATION=1 bun test src/tools/onchain.integration.test.ts
9
- *
10
- * Each write case builds the exact PTB its tool builds and simulates it against
11
- * mainnet, proving the SDK integration + PTB are valid on-chain without moving
12
- * funds. Read-only tools are invoked directly (safe).
13
- */
14
-
15
- import { describe, expect, test } from 'bun:test'
16
- import { Transaction } from '@mysten/sui/transactions'
17
- import { LENDING_MARKET_ID, LENDING_MARKET_TYPE, SuilendClient } from '@suilend/sdk'
18
- import { stakeTovSuiPTB } from 'navi-sdk'
19
- import { keypairFromSecret, makeSuiClient } from '../client'
20
- import type { OnchainRuntimeContext } from '../types'
21
- import { makeScallopMarkets } from './scallop'
22
- import { makeSuilendPosition } from './suilend'
23
-
24
- const SUI = '0x2::sui::SUI'
25
- const RUN = process.env.LYRA_RUN_INTEGRATION === '1' && !!process.env.LYRA_AGENT_KEY
26
- const ONE_SUI = 1_000_000_000n
27
-
28
- function realCtx(): OnchainRuntimeContext {
29
- const client = makeSuiClient('mainnet')
30
- const keypair = keypairFromSecret(process.env.LYRA_AGENT_KEY as string)
31
- return {
32
- client,
33
- keypair,
34
- agentAddress: keypair.getPublicKey().toSuiAddress(),
35
- network: 'mainnet',
36
- agentDir: '/tmp/lyra-integration',
37
- }
38
- }
39
-
40
- async function simStatus(ctx: OnchainRuntimeContext, tx: Transaction): Promise<string | undefined> {
41
- const r = await ctx.client.devInspectTransactionBlock({
42
- sender: ctx.agentAddress,
43
- transactionBlock: tx as never,
44
- })
45
- return r.effects?.status?.status
46
- }
47
-
48
- describe.skipIf(!RUN)('mainnet dry-run integration', () => {
49
- test('native stake (1 SUI → validator) simulates success', async () => {
50
- const ctx = realCtx()
51
- const state = await ctx.client.getLatestSuiSystemState()
52
- const validator = state.activeValidators
53
- .slice()
54
- .sort((a, b) => Number(b.votingPower) - Number(a.votingPower))[0]
55
- if (!validator) throw new Error('no active validators')
56
- const tx = new Transaction()
57
- tx.setSender(ctx.agentAddress)
58
- const [coin] = tx.splitCoins(tx.gas, [ONE_SUI])
59
- tx.moveCall({
60
- target: '0x3::sui_system::request_add_stake',
61
- arguments: [tx.object('0x5'), coin, tx.pure.address(validator.suiAddress)],
62
- })
63
- expect(await simStatus(ctx, tx)).toBe('success')
64
- }, 45_000)
65
-
66
- test('Volo liquid stake (1 SUI → vSUI) simulates success', async () => {
67
- const ctx = realCtx()
68
- const tx = new Transaction()
69
- tx.setSender(ctx.agentAddress)
70
- const [coin] = tx.splitCoins(tx.gas, [ONE_SUI])
71
- const vsui = await stakeTovSuiPTB(tx as never, coin as never)
72
- tx.transferObjects([vsui as never], ctx.agentAddress)
73
- expect(await simStatus(ctx, tx)).toBe('success')
74
- }, 45_000)
75
-
76
- test('Suilend supply (1 SUI) simulates success', async () => {
77
- const ctx = realCtx()
78
- const suilend = await SuilendClient.initialize(
79
- LENDING_MARKET_ID,
80
- LENDING_MARKET_TYPE,
81
- ctx.client as never,
82
- )
83
- const caps = await SuilendClient.getObligationOwnerCaps(
84
- ctx.agentAddress,
85
- [LENDING_MARKET_TYPE],
86
- ctx.client as never,
87
- )
88
- const tx = new Transaction()
89
- tx.setSender(ctx.agentAddress)
90
- const [coin] = tx.splitCoins(tx.gas, [ONE_SUI])
91
- if (caps.length === 0) {
92
- const cap = suilend.createObligation(tx as never)
93
- suilend.deposit(coin as never, SUI, cap, tx as never)
94
- tx.transferObjects([cap as never], ctx.agentAddress)
95
- } else {
96
- const c = caps[0] as { id: string | { id: string } }
97
- const capId = typeof c.id === 'string' ? c.id : c.id.id
98
- suilend.deposit(coin as never, SUI, capId, tx as never)
99
- }
100
- expect(await simStatus(ctx, tx)).toBe('success')
101
- }, 45_000)
102
-
103
- test('read-only tools return live data', async () => {
104
- const ctx = realCtx()
105
- const position = await makeSuilendPosition(ctx).handler({})
106
- expect(position.ok).toBe(true)
107
-
108
- const markets = await makeScallopMarkets(ctx).handler({ coins: 'sui' })
109
- expect(markets.ok).toBe(true)
110
- }, 45_000)
111
- })
@@ -1,142 +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 { makeScallopBorrow, makeScallopRepay } from './scallop'
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
- 'scallop.borrow',
60
- 'scallop.repay',
61
- 'navi.supply',
62
- 'navi.borrow',
63
- 'navi.repay',
64
- 'suilend.supply',
65
- 'suilend.withdraw',
66
- 'suilend.borrow',
67
- 'suilend.repay',
68
- 'suilend.position',
69
- // staking: native + Volo liquid staking
70
- 'sui.stake',
71
- 'sui.unstake',
72
- 'volo.stake',
73
- 'volo.unstake',
74
- // swap aggregator
75
- 'swap',
76
- ]) {
77
- expect(names).toContain(expected)
78
- }
79
- })
80
-
81
- test('registers nothing without an onchain runtime (safe no-op for loaders)', () => {
82
- const names: string[] = []
83
- const ctx = {
84
- registerTool: (def: ToolDef) => names.push(def.name),
85
- registerListener: () => {},
86
- addHook: () => {},
87
- network: 'mainnet',
88
- agentDir: '/tmp',
89
- agentId: 'test',
90
- } as unknown as PluginContext
91
- plugin.register(ctx)
92
- expect(names).toHaveLength(0)
93
- })
94
- })
95
-
96
- describe('write-tool guards (fail before network)', () => {
97
- test('suilend.supply rejects a dust amount below the supply minimum', async () => {
98
- const res = await makeSuilendSupply(stubCtx()).handler({ amount: '0.001' })
99
- expect(res.ok).toBe(false)
100
- if (!res.ok) expect(res.error).toContain('amount too small')
101
- })
102
-
103
- test('suilend.borrow rejects a dust amount below the borrow minimum', async () => {
104
- const res = await makeSuilendBorrow(stubCtx()).handler({ amount: '0.0001' })
105
- expect(res.ok).toBe(false)
106
- if (!res.ok) expect(res.error).toContain('amount too small')
107
- })
108
-
109
- test('volo.stake rejects below the 1 SUI staking minimum', async () => {
110
- const res = await makeVoloStake(stubCtx()).handler({ amount: '0.1' })
111
- expect(res.ok).toBe(false)
112
- if (!res.ok) expect(res.error).toContain('amount too small')
113
- })
114
-
115
- test('scallop.borrow rejects a dust amount', async () => {
116
- const res = await makeScallopBorrow(stubCtx()).handler({ amount: '0.001' })
117
- expect(res.ok).toBe(false)
118
- if (!res.ok) expect(res.error).toContain('amount too small')
119
- })
120
-
121
- test('invalid amount strings are rejected before any network call', async () => {
122
- const res = await makeVoloStake(stubCtx()).handler({ amount: 'not-a-number' })
123
- expect(res.ok).toBe(false)
124
- if (!res.ok) expect(res.error).toContain('invalid amount')
125
- })
126
-
127
- test('mainnet-only tools refuse to run on testnet', async () => {
128
- const supply = await makeSuilendSupply(stubCtx('testnet')).handler({ amount: '5' })
129
- expect(supply.ok).toBe(false)
130
- if (!supply.ok) expect(supply.error).toContain('mainnet only')
131
-
132
- const stake = await makeVoloStake(stubCtx('testnet')).handler({ amount: '5' })
133
- expect(stake.ok).toBe(false)
134
- if (!stake.ok) expect(stake.error).toContain('mainnet only')
135
- })
136
-
137
- test('scallop.repay is a registered, well-formed tool', () => {
138
- const tool = makeScallopRepay(stubCtx())
139
- expect(tool.name).toBe('scallop.repay')
140
- expect(typeof tool.handler).toBe('function')
141
- })
142
- })