lyra-plugin-onchain 0.1.0
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/README.md +26 -0
- package/package.json +55 -0
- package/src/approval.ts +50 -0
- package/src/client.ts +32 -0
- package/src/derive.test.ts +45 -0
- package/src/derive.ts +39 -0
- package/src/guidance.ts +47 -0
- package/src/index.ts +94 -0
- package/src/policy.test.ts +187 -0
- package/src/policy.ts +267 -0
- package/src/protocols.ts +109 -0
- package/src/simulate.ts +60 -0
- package/src/tools/balance.ts +81 -0
- package/src/tools/cetus.ts +93 -0
- package/src/tools/deepbook.ts +60 -0
- package/src/tools/defillama.ts +89 -0
- package/src/tools/navi.ts +189 -0
- package/src/tools/policy.ts +180 -0
- package/src/tools/protocols.ts +38 -0
- package/src/tools/scallop.ts +217 -0
- package/src/tools/send.ts +121 -0
- package/src/tools/swap.ts +171 -0
- package/src/tools/walrus.ts +66 -0
- package/src/types.ts +32 -0
- package/src/vault.ts +76 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scallop lending tools (supporting integration): markets + position (read) and
|
|
3
|
+
* SUI supply/withdraw (policy-gated writes).
|
|
4
|
+
*
|
|
5
|
+
* Scallop is an over-collateralized money market on Sui (mainnet-only SDK).
|
|
6
|
+
* Writes are SUI-denominated so the MIST policy cap applies directly, and they
|
|
7
|
+
* route through the same policy → simulate → execute pipeline as sui.send. The
|
|
8
|
+
* dry-run simulate is the safety net: a malformed or out-of-funds supply/
|
|
9
|
+
* withdraw is caught before broadcast.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Scallop } from '@scallop-io/sui-scallop-sdk'
|
|
13
|
+
import type { ToolDef } from 'lyra-core'
|
|
14
|
+
import { z } from 'zod'
|
|
15
|
+
import { evaluatePolicy, suiToMist } from '../policy'
|
|
16
|
+
import { simulate } from '../simulate'
|
|
17
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
18
|
+
|
|
19
|
+
const SUI_TYPE = '0x2::sui::SUI'
|
|
20
|
+
|
|
21
|
+
function ensureMainnet(ctx: OnchainRuntimeContext): string | null {
|
|
22
|
+
return ctx.network === 'mainnet' ? null : 'Scallop SDK supports mainnet only'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function newScallop(ctx: OnchainRuntimeContext): Promise<Scallop> {
|
|
26
|
+
return new Scallop({ networkType: 'mainnet', walletAddress: ctx.agentAddress })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- scallop.markets -------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const MarketsSchema = z.object({
|
|
32
|
+
coins: z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Comma-separated coin names, e.g. "sui,usdc,usdt". Default sui,usdc,usdt.'),
|
|
36
|
+
})
|
|
37
|
+
type MarketsArgs = z.infer<typeof MarketsSchema>
|
|
38
|
+
|
|
39
|
+
export function makeScallopMarkets(ctx: OnchainRuntimeContext): ToolDef<MarketsArgs> {
|
|
40
|
+
return {
|
|
41
|
+
name: 'scallop.markets',
|
|
42
|
+
description:
|
|
43
|
+
'Read Scallop lending markets on Sui: supply APY, borrow APY, and utilization per asset. Read-only discovery for where idle funds could earn yield.',
|
|
44
|
+
searchHint: 'scallop lending market supply apy borrow yield utilization rates earn',
|
|
45
|
+
schema: MarketsSchema,
|
|
46
|
+
handler: async args => {
|
|
47
|
+
const err = ensureMainnet(ctx)
|
|
48
|
+
if (err) return { ok: false, error: err }
|
|
49
|
+
try {
|
|
50
|
+
const sdk = await newScallop(ctx)
|
|
51
|
+
const q = await sdk.createScallopQuery()
|
|
52
|
+
await q.init()
|
|
53
|
+
const coins = args.coins
|
|
54
|
+
?.split(',')
|
|
55
|
+
.map(s => s.trim())
|
|
56
|
+
.filter(Boolean) ?? ['sui', 'usdc', 'usdt']
|
|
57
|
+
const pools = await Promise.all(
|
|
58
|
+
coins.map(async coin => {
|
|
59
|
+
try {
|
|
60
|
+
const p = await q.getMarketPool(coin)
|
|
61
|
+
return {
|
|
62
|
+
coin,
|
|
63
|
+
supplyApy: p?.supplyApy ?? null,
|
|
64
|
+
borrowApy: p?.borrowApy ?? null,
|
|
65
|
+
utilization: p?.utilizationRate ?? null,
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { coin, error: (e as Error).message.slice(0, 100) }
|
|
69
|
+
}
|
|
70
|
+
}),
|
|
71
|
+
)
|
|
72
|
+
return { ok: true, data: { protocol: 'scallop', network: 'mainnet', pools } }
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- scallop.position ------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
const PositionSchema = z.object({})
|
|
83
|
+
type PositionArgs = z.infer<typeof PositionSchema>
|
|
84
|
+
|
|
85
|
+
export function makeScallopPosition(ctx: OnchainRuntimeContext): ToolDef<PositionArgs> {
|
|
86
|
+
return {
|
|
87
|
+
name: 'scallop.position',
|
|
88
|
+
description:
|
|
89
|
+
"The agent's Scallop position: total supplied, collateral, debt, and per-asset lendings/borrowings. Read-only.",
|
|
90
|
+
searchHint: 'scallop position portfolio supplied collateral debt lending borrowing health',
|
|
91
|
+
schema: PositionSchema,
|
|
92
|
+
handler: async () => {
|
|
93
|
+
const err = ensureMainnet(ctx)
|
|
94
|
+
if (err) return { ok: false, error: err }
|
|
95
|
+
try {
|
|
96
|
+
const sdk = await newScallop(ctx)
|
|
97
|
+
const q = await sdk.createScallopQuery()
|
|
98
|
+
await q.init()
|
|
99
|
+
const p = await q.getUserPortfolio({ walletAddress: ctx.agentAddress })
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
data: {
|
|
103
|
+
protocol: 'scallop',
|
|
104
|
+
totalSupplyValue: p?.totalSupplyValue ?? 0,
|
|
105
|
+
totalCollateralValue: p?.totalCollateralValue ?? 0,
|
|
106
|
+
totalDebtValue: p?.totalDebtValue ?? 0,
|
|
107
|
+
lendings: (p?.lendings ?? []).map(
|
|
108
|
+
(l: { coinName?: string; suppliedCoin?: number; suppliedValue?: number }) => ({
|
|
109
|
+
coin: l.coinName,
|
|
110
|
+
supplied: l.suppliedCoin,
|
|
111
|
+
value: l.suppliedValue,
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
borrowings: p?.borrowings ?? [],
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- scallop.supply / scallop.withdraw (SUI) -------------------------------
|
|
125
|
+
|
|
126
|
+
const AmountSchema = z.object({
|
|
127
|
+
amount: z.string().min(1).describe('Amount of SUI, e.g. "1.5".'),
|
|
128
|
+
})
|
|
129
|
+
type AmountArgs = z.infer<typeof AmountSchema>
|
|
130
|
+
|
|
131
|
+
async function runScallopWrite(
|
|
132
|
+
ctx: OnchainRuntimeContext,
|
|
133
|
+
amount: string,
|
|
134
|
+
kind: 'supply' | 'withdraw',
|
|
135
|
+
): Promise<{ ok: true; data: unknown } | { ok: false; error: string }> {
|
|
136
|
+
const err = ensureMainnet(ctx)
|
|
137
|
+
if (err) return { ok: false, error: err }
|
|
138
|
+
const amountMist = suiToMist(amount)
|
|
139
|
+
if (amountMist === undefined || amountMist <= 0n)
|
|
140
|
+
return { ok: false, error: `invalid amount "${amount}"` }
|
|
141
|
+
|
|
142
|
+
// Policy gate (deterministic). Supplying moves funds out; withdrawing back in.
|
|
143
|
+
if (ctx.policy && kind === 'supply') {
|
|
144
|
+
const verdict = evaluatePolicy(
|
|
145
|
+
{ kind: 'transfer', coinType: SUI_TYPE, amountMist, protocol: 'scallop' },
|
|
146
|
+
ctx.policy,
|
|
147
|
+
)
|
|
148
|
+
if (!verdict.allowed)
|
|
149
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const sdk = await newScallop(ctx)
|
|
154
|
+
const builder = await sdk.createScallopBuilder()
|
|
155
|
+
const tx = builder.createTxBlock()
|
|
156
|
+
tx.setSender(ctx.agentAddress)
|
|
157
|
+
// deposit/withdraw auto-select the user's coins and return the resulting
|
|
158
|
+
// coin to hand back to the owner. depositQuick's 3rd arg returnSCoin=false
|
|
159
|
+
// keeps the old market-coin standard, which withdrawQuick can redeem (the
|
|
160
|
+
// new sCoin standard is not found by withdrawQuick → "No valid coins").
|
|
161
|
+
const out =
|
|
162
|
+
kind === 'supply'
|
|
163
|
+
? await tx.depositQuick(Number(amountMist), 'sui', false)
|
|
164
|
+
: await tx.withdrawQuick(Number(amountMist), 'sui')
|
|
165
|
+
if (out) tx.transferObjects([out], ctx.agentAddress)
|
|
166
|
+
const transaction = tx.txBlock
|
|
167
|
+
|
|
168
|
+
const sim = await simulate(ctx.client, transaction, ctx.agentAddress)
|
|
169
|
+
if (!sim.ok) return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
170
|
+
|
|
171
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
172
|
+
signer: ctx.keypair,
|
|
173
|
+
transaction,
|
|
174
|
+
options: { showEffects: true },
|
|
175
|
+
})
|
|
176
|
+
if (res.effects?.status?.status !== 'success') {
|
|
177
|
+
return { ok: false, error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}` }
|
|
178
|
+
}
|
|
179
|
+
// Wait for the tx to settle/index so a follow-up action (e.g. an immediate
|
|
180
|
+
// withdraw after a supply) doesn't race the not-yet-indexed position.
|
|
181
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
data: {
|
|
185
|
+
protocol: 'scallop',
|
|
186
|
+
action: kind,
|
|
187
|
+
amountSui: amount,
|
|
188
|
+
digest: res.digest,
|
|
189
|
+
simGasUsed: sim.gasUsed,
|
|
190
|
+
policyEnforced: ctx.policy != null,
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
} catch (e) {
|
|
194
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function makeScallopSupply(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
199
|
+
return {
|
|
200
|
+
name: 'scallop.supply',
|
|
201
|
+
description:
|
|
202
|
+
'Supply (deposit) idle SUI into Scallop to earn lending yield. Policy-checked, simulated, then executed.',
|
|
203
|
+
searchHint: 'scallop supply deposit lend sui earn yield idle',
|
|
204
|
+
schema: AmountSchema,
|
|
205
|
+
handler: async args => runScallopWrite(ctx, args.amount, 'supply'),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function makeScallopWithdraw(ctx: OnchainRuntimeContext): ToolDef<AmountArgs> {
|
|
210
|
+
return {
|
|
211
|
+
name: 'scallop.withdraw',
|
|
212
|
+
description: 'Withdraw supplied SUI from Scallop back to the agent. Simulated, then executed.',
|
|
213
|
+
searchHint: 'scallop withdraw redeem unlend remove sui',
|
|
214
|
+
schema: AmountSchema,
|
|
215
|
+
handler: async args => runScallopWrite(ctx, args.amount, 'withdraw'),
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sui.send` — transfer SUI through the full guarded pipeline:
|
|
3
|
+
* policy → simulate → execute → on-chain lyra::policy receipt.
|
|
4
|
+
*
|
|
5
|
+
* The deterministic policy is checked in code BEFORE anything is built. When a
|
|
6
|
+
* shared AgentPolicy object is configured, the PTB also calls
|
|
7
|
+
* `lyra::policy::record_action`, so the SAME on-chain limits are re-enforced in
|
|
8
|
+
* Move and an ActionReceipt is minted atomically with the transfer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Transaction } from '@mysten/sui/transactions'
|
|
12
|
+
import type { ToolDef } from 'lyra-core'
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
import { evaluatePolicy, suiToMist } from '../policy'
|
|
15
|
+
import { simulate } from '../simulate'
|
|
16
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
17
|
+
|
|
18
|
+
const SUI_TYPE = '0x2::sui::SUI'
|
|
19
|
+
|
|
20
|
+
const Schema = z.object({
|
|
21
|
+
to: z.string().min(3).describe('Recipient Sui address (0x...).'),
|
|
22
|
+
amount: z.string().min(1).describe('Amount in SUI, e.g. "0.05".'),
|
|
23
|
+
})
|
|
24
|
+
type Args = z.infer<typeof Schema>
|
|
25
|
+
|
|
26
|
+
export function makeSuiSend(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
27
|
+
return {
|
|
28
|
+
name: 'sui.send',
|
|
29
|
+
description:
|
|
30
|
+
'Transfer SUI from the agent to a recipient. Deterministically policy-checked, dry-run simulated, then executed; mints an on-chain lyra::policy ActionReceipt when a policy object is configured.',
|
|
31
|
+
searchHint: 'send transfer sui pay native move funds remit',
|
|
32
|
+
schema: Schema,
|
|
33
|
+
handler: async args => {
|
|
34
|
+
try {
|
|
35
|
+
const to = args.to.trim()
|
|
36
|
+
if (!to.startsWith('0x')) {
|
|
37
|
+
return { ok: false, error: `invalid recipient "${to}": expected a 0x Sui address` }
|
|
38
|
+
}
|
|
39
|
+
const amountMist = suiToMist(args.amount)
|
|
40
|
+
if (amountMist === undefined || amountMist <= 0n) {
|
|
41
|
+
return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 1. Policy gate (deterministic) — block before simulate/execute.
|
|
45
|
+
if (ctx.policy) {
|
|
46
|
+
const verdict = evaluatePolicy(
|
|
47
|
+
{ kind: 'transfer', coinType: SUI_TYPE, amountMist, to, protocol: 'transfer' },
|
|
48
|
+
ctx.policy,
|
|
49
|
+
)
|
|
50
|
+
if (!verdict.allowed) {
|
|
51
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Build the PTB: split + transfer, plus on-chain receipt/enforcement.
|
|
56
|
+
const tx = new Transaction()
|
|
57
|
+
const [coin] = tx.splitCoins(tx.gas, [amountMist])
|
|
58
|
+
tx.transferObjects([coin], to)
|
|
59
|
+
const recordsOnChain = Boolean(ctx.packageId && ctx.policyObjectId)
|
|
60
|
+
if (recordsOnChain) {
|
|
61
|
+
tx.moveCall({
|
|
62
|
+
target: `${ctx.packageId}::policy::record_action`,
|
|
63
|
+
typeArguments: [SUI_TYPE],
|
|
64
|
+
arguments: [
|
|
65
|
+
tx.object(ctx.policyObjectId as string),
|
|
66
|
+
tx.pure.u64(amountMist),
|
|
67
|
+
tx.pure.address('0x0'),
|
|
68
|
+
tx.pure.vector('u8', Array.from(new TextEncoder().encode('transfer'))),
|
|
69
|
+
tx.pure.vector('u8', Array.from(new TextEncoder().encode(`to ${to}`))),
|
|
70
|
+
tx.object.clock(),
|
|
71
|
+
],
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 3. Simulate-before-write.
|
|
76
|
+
const sim = await simulate(ctx.client, tx, ctx.agentAddress)
|
|
77
|
+
if (!sim.ok) {
|
|
78
|
+
return { ok: false, error: `pre-flight simulation failed: ${sim.reason}` }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 4. Execute.
|
|
82
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
83
|
+
signer: ctx.keypair,
|
|
84
|
+
transaction: tx,
|
|
85
|
+
options: { showEffects: true, showObjectChanges: true },
|
|
86
|
+
})
|
|
87
|
+
if (res.effects?.status?.status !== 'success') {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Settle/index before returning so a follow-up balance read is accurate.
|
|
94
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
95
|
+
const receipt = res.objectChanges?.find(
|
|
96
|
+
c =>
|
|
97
|
+
c.type === 'created' &&
|
|
98
|
+
String((c as { objectType?: string }).objectType).endsWith('::policy::ActionReceipt'),
|
|
99
|
+
) as { objectId?: string } | undefined
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
data: {
|
|
104
|
+
digest: res.digest,
|
|
105
|
+
recipient: to,
|
|
106
|
+
amountSui: args.amount,
|
|
107
|
+
amountMist: amountMist.toString(),
|
|
108
|
+
status: 'success',
|
|
109
|
+
// Decision receipt: proof this write was policy-checked + simulated.
|
|
110
|
+
simGasUsed: sim.gasUsed,
|
|
111
|
+
policyEnforced: ctx.policy != null,
|
|
112
|
+
onchainReceipt: recordsOnChain,
|
|
113
|
+
receiptId: receipt?.objectId ?? null,
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `swap` — execute a token swap on Sui via the 7k aggregator (best route across
|
|
3
|
+
* Cetus, FlowX, Bluefin, DeepBook, ...). Runs through the same policy → simulate
|
|
4
|
+
* → execute pipeline as every other write.
|
|
5
|
+
*
|
|
6
|
+
* Why 7k and not the Cetus aggregator directly: Cetus's aggregator SDK pins
|
|
7
|
+
* @mysten/sui v2 (incompatible with this stack's v1.45), and DeepBook's own
|
|
8
|
+
* swap needs DEEP for fees + a 1-SUI min lot. 7k (@7kprotocol/sdk-ts) is
|
|
9
|
+
* v1-compatible and routes across all of them, paying fees from the route.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { MetaAg } from '@7kprotocol/sdk-ts'
|
|
13
|
+
import { Transaction, coinWithBalance } from '@mysten/sui/transactions'
|
|
14
|
+
import type { ToolDef } from 'lyra-core'
|
|
15
|
+
import { z } from 'zod'
|
|
16
|
+
import { evaluatePolicy } from '../policy'
|
|
17
|
+
import { simulate } from '../simulate'
|
|
18
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
19
|
+
|
|
20
|
+
const SUI_TYPE = '0x2::sui::SUI'
|
|
21
|
+
|
|
22
|
+
/** Symbol → mainnet coin type + decimals for the common assets. */
|
|
23
|
+
const COINS: Record<string, { type: string; decimals: number }> = {
|
|
24
|
+
sui: { type: SUI_TYPE, decimals: 9 },
|
|
25
|
+
usdc: {
|
|
26
|
+
type: '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC',
|
|
27
|
+
decimals: 6,
|
|
28
|
+
},
|
|
29
|
+
deep: {
|
|
30
|
+
type: '0xdeeb7a4662eec9f2f3def03fb937a663dddaa2e215b8078a284d026b7946c270::deep::DEEP',
|
|
31
|
+
decimals: 6,
|
|
32
|
+
},
|
|
33
|
+
wal: {
|
|
34
|
+
type: '0x356a26eb9e012a68958082340d4c4116e7f55615cf27affcff209cf0ae544f59::wal::WAL',
|
|
35
|
+
decimals: 9,
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolve(input: string): { type: string; decimals: number } {
|
|
40
|
+
const k = input.trim().toLowerCase()
|
|
41
|
+
return COINS[k] ?? { type: input.trim(), decimals: 9 }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const Schema = z.object({
|
|
45
|
+
from: z.string().min(1).describe('Input coin: symbol (sui, usdc, deep, wal) or full coin type.'),
|
|
46
|
+
to: z.string().min(1).describe('Output coin: symbol or full coin type.'),
|
|
47
|
+
amount: z.string().min(1).describe('Input amount in whole units of `from`, e.g. "1".'),
|
|
48
|
+
})
|
|
49
|
+
type Args = z.infer<typeof Schema>
|
|
50
|
+
|
|
51
|
+
export function makeSwap(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
52
|
+
return {
|
|
53
|
+
name: 'swap',
|
|
54
|
+
description:
|
|
55
|
+
'Swap one coin for another on Sui via the 7k aggregator (best route across Cetus/FlowX/Bluefin/DeepBook). Policy-checked → simulated → executed. Use for "swap 1 SUI to USDC", "trade X for Y".',
|
|
56
|
+
searchHint: 'swap exchange trade convert dex aggregator best route sui usdc buy sell',
|
|
57
|
+
schema: Schema,
|
|
58
|
+
handler: async args => {
|
|
59
|
+
if (ctx.network !== 'mainnet') return { ok: false, error: 'swap supports mainnet only' }
|
|
60
|
+
try {
|
|
61
|
+
const from = resolve(args.from)
|
|
62
|
+
const to = resolve(args.to)
|
|
63
|
+
if (from.type === to.type) return { ok: false, error: 'from and to are the same coin' }
|
|
64
|
+
const amountIn = BigInt(Math.round(Number(args.amount) * 10 ** from.decimals))
|
|
65
|
+
if (amountIn <= 0n) return { ok: false, error: `invalid amount "${args.amount}"` }
|
|
66
|
+
|
|
67
|
+
// Policy gate. The MIST per-tx cap is SUI-denominated, so it only bounds
|
|
68
|
+
// SUI-input swaps; the slippage/protocol/expiry checks always apply.
|
|
69
|
+
if (ctx.policy) {
|
|
70
|
+
const verdict = evaluatePolicy(
|
|
71
|
+
{
|
|
72
|
+
kind: 'swap',
|
|
73
|
+
coinType: from.type,
|
|
74
|
+
amountMist: from.type === SUI_TYPE ? amountIn : 0n,
|
|
75
|
+
toCoinType: to.type,
|
|
76
|
+
protocol: 'swap',
|
|
77
|
+
slippageBps: ctx.policy.maxSlippageBps,
|
|
78
|
+
},
|
|
79
|
+
ctx.policy,
|
|
80
|
+
)
|
|
81
|
+
if (!verdict.allowed)
|
|
82
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const me = ctx.agentAddress
|
|
86
|
+
const ag = new MetaAg({ slippageBps: ctx.policy?.maxSlippageBps ?? 100 })
|
|
87
|
+
// Quote without the SDK's internal pre-simulation (it throws on routes it
|
|
88
|
+
// can't simulate); we simulate the chosen route ourselves below.
|
|
89
|
+
const quotes = (
|
|
90
|
+
await ag.quote({
|
|
91
|
+
coinTypeIn: from.type,
|
|
92
|
+
coinTypeOut: to.type,
|
|
93
|
+
amountIn: amountIn.toString(),
|
|
94
|
+
signer: me,
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.sort((a, b) => Number(b.amountOut ?? 0) - Number(a.amountOut ?? 0))
|
|
99
|
+
if (quotes.length === 0) return { ok: false, error: 'no swap route found' }
|
|
100
|
+
|
|
101
|
+
// Best-execution with reliability: try routes in output order, skipping
|
|
102
|
+
// any that fail simulation (some providers build PTBs that leave an
|
|
103
|
+
// unconsumed value / revert). Use the first that simulates cleanly.
|
|
104
|
+
let used: {
|
|
105
|
+
provider: string
|
|
106
|
+
amountOut: number
|
|
107
|
+
tx: Transaction
|
|
108
|
+
gasUsed?: string
|
|
109
|
+
} | null = null
|
|
110
|
+
const failures: string[] = []
|
|
111
|
+
for (const q of quotes) {
|
|
112
|
+
const tx = new Transaction()
|
|
113
|
+
tx.setSender(me)
|
|
114
|
+
tx.setGasBudget(150_000_000)
|
|
115
|
+
try {
|
|
116
|
+
const coinIn =
|
|
117
|
+
from.type === SUI_TYPE
|
|
118
|
+
? tx.splitCoins(tx.gas, [amountIn])[0]
|
|
119
|
+
: coinWithBalance({ type: from.type, balance: amountIn })
|
|
120
|
+
const coinOut = await ag.swap({ quote: q, signer: me, tx, coinIn: coinIn as never })
|
|
121
|
+
tx.transferObjects([coinOut as never], me)
|
|
122
|
+
const sim = await simulate(ctx.client, tx, me)
|
|
123
|
+
if (sim.ok) {
|
|
124
|
+
used = {
|
|
125
|
+
provider: String(q.provider),
|
|
126
|
+
amountOut: Number(q.amountOut ?? 0),
|
|
127
|
+
tx,
|
|
128
|
+
gasUsed: sim.gasUsed,
|
|
129
|
+
}
|
|
130
|
+
break
|
|
131
|
+
}
|
|
132
|
+
failures.push(`${q.provider}: ${sim.reason}`)
|
|
133
|
+
} catch (e) {
|
|
134
|
+
failures.push(`${q.provider}: ${(e as Error).message.slice(0, 60)}`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!used)
|
|
138
|
+
return { ok: false, error: `no route simulated cleanly — ${failures.join(' | ')}` }
|
|
139
|
+
|
|
140
|
+
const res = await ctx.client.signAndExecuteTransaction({
|
|
141
|
+
signer: ctx.keypair,
|
|
142
|
+
transaction: used.tx,
|
|
143
|
+
options: { showEffects: true },
|
|
144
|
+
})
|
|
145
|
+
if (res.effects?.status?.status !== 'success') {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
error: `execution failed: ${res.effects?.status?.error ?? 'unknown'}`,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
await ctx.client.waitForTransaction({ digest: res.digest })
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
ok: true,
|
|
155
|
+
data: {
|
|
156
|
+
from: args.from,
|
|
157
|
+
to: args.to,
|
|
158
|
+
amountIn: args.amount,
|
|
159
|
+
amountOut: (used.amountOut / 10 ** to.decimals).toString(),
|
|
160
|
+
route: used.provider,
|
|
161
|
+
digest: res.digest,
|
|
162
|
+
simGasUsed: used.gasUsed,
|
|
163
|
+
policyEnforced: ctx.policy != null,
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `walrus.store` — write a durable, verifiable artifact (receipt, report,
|
|
3
|
+
* memory snapshot) to Walrus and return its blobId.
|
|
4
|
+
*
|
|
5
|
+
* This is what makes a short-lived action auditable across sessions: the agent
|
|
6
|
+
* stores receipts and analysis on Walrus, and on-chain ActionReceipts can
|
|
7
|
+
* reference the blob. Storing costs WAL from the agent address.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { WalrusClient } from '@mysten/walrus'
|
|
11
|
+
import type { ToolDef } from 'lyra-core'
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
14
|
+
|
|
15
|
+
const Schema = z.object({
|
|
16
|
+
content: z.string().min(1).describe('The text/JSON artifact to store durably.'),
|
|
17
|
+
epochs: z
|
|
18
|
+
.number()
|
|
19
|
+
.int()
|
|
20
|
+
.min(1)
|
|
21
|
+
.max(53)
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('Storage duration in Walrus epochs. Default 3.'),
|
|
24
|
+
})
|
|
25
|
+
type Args = z.infer<typeof Schema>
|
|
26
|
+
|
|
27
|
+
export function makeWalrusStore(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
28
|
+
return {
|
|
29
|
+
name: 'walrus.store',
|
|
30
|
+
description:
|
|
31
|
+
'Store a durable, verifiable artifact (receipt, report, or memory) on Walrus. Returns a blobId you can cite later. Use to persist agent memory or an execution receipt across sessions.',
|
|
32
|
+
searchHint: 'walrus store blob save receipt memory durable artifact persist record',
|
|
33
|
+
schema: Schema,
|
|
34
|
+
handler: async args => {
|
|
35
|
+
try {
|
|
36
|
+
const walrus = new WalrusClient({
|
|
37
|
+
network: ctx.walrusNetwork ?? ctx.network,
|
|
38
|
+
// cast: @mysten/walrus bundles a different @mysten/sui minor; runtime-
|
|
39
|
+
// compatible (verified: real mainnet blob written) but not type-identical.
|
|
40
|
+
suiClient: ctx.client as never,
|
|
41
|
+
})
|
|
42
|
+
const blob = new TextEncoder().encode(args.content)
|
|
43
|
+
const result = await walrus.writeBlob({
|
|
44
|
+
blob,
|
|
45
|
+
deletable: false,
|
|
46
|
+
epochs: args.epochs ?? 3,
|
|
47
|
+
signer: ctx.keypair as never,
|
|
48
|
+
})
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
data: {
|
|
52
|
+
blobId: result.blobId,
|
|
53
|
+
bytes: blob.length,
|
|
54
|
+
epochs: args.epochs ?? 3,
|
|
55
|
+
network: ctx.walrusNetwork ?? ctx.network,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: `walrus store failed (needs WAL for storage): ${(e as Error).message.slice(0, 200)}`,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for lyra-plugin-onchain. The runtime context is side-banded onto
|
|
3
|
+
* PluginContext under `.onchain` (the harness builds it; the plugin reads it via
|
|
4
|
+
* `(ctx as any).onchain`), keeping PluginContext free of plugin-specific fields.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { SuiClient } from '@mysten/sui/client'
|
|
8
|
+
import type { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
|
|
9
|
+
import type { SuiNetwork } from './client'
|
|
10
|
+
import type { SuiPolicy } from './policy'
|
|
11
|
+
|
|
12
|
+
export interface OnchainRuntimeContext {
|
|
13
|
+
/** Sui JSON-RPC client for `network`. */
|
|
14
|
+
client: SuiClient
|
|
15
|
+
/** The agent signer (signs + pays gas). */
|
|
16
|
+
keypair: Ed25519Keypair
|
|
17
|
+
/** `keypair.toSuiAddress()`, cached. */
|
|
18
|
+
agentAddress: string
|
|
19
|
+
network: SuiNetwork
|
|
20
|
+
/** Deterministic fund-control policy. When set, every write is checked before simulate/execute. */
|
|
21
|
+
policy?: SuiPolicy
|
|
22
|
+
/** Deployed `lyra::policy` package id (for on-chain receipts + policy objects). */
|
|
23
|
+
packageId?: string
|
|
24
|
+
/** Shared `AgentPolicy` object id. When set, writes compose an on-chain receipt + enforcement. */
|
|
25
|
+
policyObjectId?: string
|
|
26
|
+
/** Walrus network for receipt/memory storage (defaults to `network`). */
|
|
27
|
+
walrusNetwork?: SuiNetwork
|
|
28
|
+
agentDir: string
|
|
29
|
+
/** Optional: brain provider/model, surfaced by account.info. */
|
|
30
|
+
brainProvider?: string | null
|
|
31
|
+
brainModel?: string | null
|
|
32
|
+
}
|
package/src/vault.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve an owner's on-chain setup: their `AgentPolicy` (delegating to their
|
|
3
|
+
* derived agent) + the treasury `Vault` bound to it. The binding lives on-chain
|
|
4
|
+
* (owner holds the `PolicyOwnerCap` → policy → vault), so any surface resolves
|
|
5
|
+
* "this owner's agent + vault" without a database. Mirrors
|
|
6
|
+
* `apps/web/lib/vault.ts`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type SuiNetwork, makeSuiClient } from './client'
|
|
10
|
+
import { deriveAgentAddress } from './derive'
|
|
11
|
+
|
|
12
|
+
// A module keeps its DEFINING package id for types/events: `policy` shipped in
|
|
13
|
+
// the original publish; `vault` was added in the first upgrade.
|
|
14
|
+
const ORIGINAL_PKG = '0x250880a4c1a268da8011b164f599d4e100cefce84f862d36396cd1a943ee8a35'
|
|
15
|
+
const VAULT_PKG = '0xa40689cc541f57af123e90819e73eab8a551e4385ab91bee89d02f6691590211'
|
|
16
|
+
|
|
17
|
+
export interface OwnerVault {
|
|
18
|
+
policyId: string
|
|
19
|
+
vaultId: string
|
|
20
|
+
capId: string
|
|
21
|
+
agent: string
|
|
22
|
+
vaultMist: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Find the owner's policy (delegating to their derived agent) + its SUI vault. */
|
|
26
|
+
export async function resolveOwnerVault(
|
|
27
|
+
owner: string,
|
|
28
|
+
network: SuiNetwork = 'mainnet',
|
|
29
|
+
masterSecret?: string,
|
|
30
|
+
): Promise<OwnerVault | null> {
|
|
31
|
+
const client = makeSuiClient(network)
|
|
32
|
+
const agent = deriveAgentAddress(owner, masterSecret)
|
|
33
|
+
const caps = await client
|
|
34
|
+
.getOwnedObjects({
|
|
35
|
+
owner,
|
|
36
|
+
filter: { StructType: `${ORIGINAL_PKG}::policy::PolicyOwnerCap` },
|
|
37
|
+
options: { showContent: true },
|
|
38
|
+
})
|
|
39
|
+
.catch(() => null)
|
|
40
|
+
if (!caps) return null
|
|
41
|
+
|
|
42
|
+
for (const c of caps.data) {
|
|
43
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic Move object fields
|
|
44
|
+
const policyId = (c.data?.content as any)?.fields?.policy_id as string | undefined
|
|
45
|
+
if (!policyId) continue
|
|
46
|
+
const pol = await client
|
|
47
|
+
.getObject({ id: policyId, options: { showContent: true } })
|
|
48
|
+
.catch(() => null)
|
|
49
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic Move object fields
|
|
50
|
+
const pf = (pol?.data?.content as any)?.fields
|
|
51
|
+
if (!pf || pf.agent !== agent || pf.revoked) continue
|
|
52
|
+
|
|
53
|
+
const ev = await client
|
|
54
|
+
.queryEvents({
|
|
55
|
+
query: { MoveEventType: `${VAULT_PKG}::vault::VaultOpened` },
|
|
56
|
+
limit: 100,
|
|
57
|
+
order: 'descending',
|
|
58
|
+
})
|
|
59
|
+
.catch(() => null)
|
|
60
|
+
// biome-ignore lint/suspicious/noExplicitAny: parsed Move event
|
|
61
|
+
const match = ev?.data.find(e => (e.parsedJson as any)?.policy_id === policyId)
|
|
62
|
+
// biome-ignore lint/suspicious/noExplicitAny: parsed Move event
|
|
63
|
+
const vaultId = (match?.parsedJson as any)?.vault_id as string | undefined
|
|
64
|
+
if (!vaultId) continue
|
|
65
|
+
|
|
66
|
+
const v = await client
|
|
67
|
+
.getObject({ id: vaultId, options: { showContent: true } })
|
|
68
|
+
.catch(() => null)
|
|
69
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic Move object fields
|
|
70
|
+
const vaultMist = String((v?.data?.content as any)?.fields?.balance ?? '0')
|
|
71
|
+
const capId = c.data?.objectId
|
|
72
|
+
if (!capId) continue
|
|
73
|
+
return { policyId, vaultId, capId, agent, vaultMist }
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
}
|