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 ADDED
@@ -0,0 +1,26 @@
1
+ # lyra-plugin-onchain
2
+
3
+ The **Sui limbs** for **lyra** — the brain tools that do real on-chain work,
4
+ every value-moving call routed through the deterministic policy → simulation →
5
+ approval pipeline:
6
+
7
+ - **Wallet / account** — `account.info`, `chain.balance`, `tokens.info`
8
+ - **Transfers** — `chain.send`, `chain.wrap`, `chain.unwrap`
9
+ - **Trading** — `swap.best` / `swap.compare` (**Cetus Finance** + **DeepBook**
10
+ best-execution), `swap.quote` / `swap.execute`, `moe.quote` / `moe.swap`
11
+ - **Lending** — full **Scallop V3** suite: `scallop.markets` / `position` / `supply` /
12
+ `withdraw` / `borrow` / `repay`
13
+ - **Discovery + risk** — `defi.yields` (DeFiLlama), `risk.token`, `nansen.labels`,
14
+ `cex.balance` (Bybit, read-only)
15
+ - **Identity** — `identity.resolve` / `identity.register` (**lyra::policy** Trustless
16
+ Agents)
17
+ - **Controls + analysis** — `policy.show`, `tx.simulate`, `chain.read` /
18
+ `chain.write`, `chain.tx` / `chain.contract` / `chain.activity`, `chain.block` /
19
+ `chain.gas`
20
+
21
+ ## Install
22
+
23
+ Auto-installed with [`lyra-ai-agent`](https://www.npmjs.com/package/lyra-ai-agent).
24
+ Or directly: `bun add lyra-plugin-onchain`.
25
+
26
+ See the [root README](https://github.com/rifkyeasy/lyra#readme) for the full tool reference.
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "lyra-plugin-onchain",
3
+ "version": "0.1.0",
4
+ "type": "module",
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
+ "license": "MIT",
7
+ "homepage": "https://github.com/rifkyeasy/lyra",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/rifkyeasy/lyra.git",
11
+ "directory": "packages/plugin-onchain"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/rifkyeasy/lyra/issues"
15
+ },
16
+ "keywords": [
17
+ "lyra",
18
+ "ai",
19
+ "agent",
20
+ "sui",
21
+ "defi",
22
+ "deepbook",
23
+ "walrus",
24
+ "policy",
25
+ "plugin"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "engines": {
31
+ "bun": ">=1.1"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "!src/**/*.test.ts",
36
+ "README.md"
37
+ ],
38
+ "main": "./src/index.ts",
39
+ "types": "./src/index.ts",
40
+ "scripts": {
41
+ "build": "tsc -b",
42
+ "test": "bun test"
43
+ },
44
+ "dependencies": {
45
+ "@7kprotocol/sdk-ts": "^4.0.0",
46
+ "@cetusprotocol/aggregator-sdk": "^1.6.1",
47
+ "@mysten/deepbook-v3": "^0.18.0",
48
+ "@mysten/sui": "^1.40.0",
49
+ "@mysten/walrus": "^0.6.4",
50
+ "@scallop-io/sui-scallop-sdk": "^2.4.5",
51
+ "lyra-core": "0.3.3",
52
+ "navi-sdk": "^1.7.3",
53
+ "zod": "^3.23.8"
54
+ }
55
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Permission-gate bridge between the deterministic policy engine and the
3
+ * harness permission service.
4
+ *
5
+ * The CLI + gateway pre-tool-call hooks build a permission request for every
6
+ * value-moving tool call. On its own the permission service only knows the
7
+ * session MODE (strict/prompt/off) — under YOLO it would let any in-cap spend
8
+ * through silently. This helper runs the SAME `evaluatePolicy` the tool runs and,
9
+ * when the policy flags the action as material-risk (`requiresApproval`), the
10
+ * hook forces an approval prompt beneath the session mode. Fund controls in
11
+ * code, not in the model (CLAUDE.md).
12
+ */
13
+
14
+ import { type SuiPolicy, type SuiPolicyAction, evaluatePolicy, suiToMist } from './policy'
15
+
16
+ const SUI_TYPE = '0x2::sui::SUI'
17
+
18
+ /** Map a tool call (name + raw args) to a best-effort PolicyAction. */
19
+ function actionForCall(name: string, a: Record<string, unknown>): SuiPolicyAction | null {
20
+ switch (name) {
21
+ case 'sui.send': {
22
+ const amount = typeof a.amount === 'string' ? a.amount : String(a.amount ?? '')
23
+ return {
24
+ kind: 'transfer',
25
+ coinType: SUI_TYPE,
26
+ amountMist: suiToMist(amount) ?? 0n,
27
+ to: typeof a.to === 'string' ? a.to : undefined,
28
+ protocol: 'transfer',
29
+ }
30
+ }
31
+ default:
32
+ return null
33
+ }
34
+ }
35
+
36
+ /**
37
+ * True when the policy requires human approval for this tool call (the gate
38
+ * should force a prompt regardless of mode). False when no policy is configured
39
+ * or the call is not value-moving.
40
+ */
41
+ export function policyRequiresApprovalForCall(
42
+ name: string,
43
+ args: Record<string, unknown>,
44
+ policy: SuiPolicy | undefined,
45
+ ): boolean {
46
+ if (!policy) return false
47
+ const action = actionForCall(name, args)
48
+ if (!action) return false
49
+ return evaluatePolicy(action, policy).requiresApproval
50
+ }
package/src/client.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Sui client + agent keypair construction for Lyra's on-chain plugin.
3
+ *
4
+ * One agent address signs and pays gas. The deterministic policy (off-chain
5
+ * mirror in `./policy.ts`, enforced again on-chain by `lyra::policy`) bounds
6
+ * what it may do.
7
+ */
8
+
9
+ import { SuiClient, getFullnodeUrl } from '@mysten/sui/client'
10
+ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
11
+
12
+ export type SuiNetwork = 'testnet' | 'mainnet'
13
+
14
+ /** Canonical fullnode JSON-RPC URL for a network. */
15
+ export function suiRpcUrl(network: SuiNetwork): string {
16
+ return getFullnodeUrl(network)
17
+ }
18
+
19
+ /** A Sui JSON-RPC client for the given network. */
20
+ export function makeSuiClient(network: SuiNetwork): SuiClient {
21
+ return new SuiClient({ url: getFullnodeUrl(network) })
22
+ }
23
+
24
+ /**
25
+ * Build the agent keypair from a secret. Accepts a Sui bech32 secret
26
+ * (`suiprivkey1...`, preferred) or a base64-encoded 32-byte seed.
27
+ */
28
+ export function keypairFromSecret(secret: string): Ed25519Keypair {
29
+ const s = secret.trim()
30
+ if (s.startsWith('suiprivkey')) return Ed25519Keypair.fromSecretKey(s)
31
+ return Ed25519Keypair.fromSecretKey(Uint8Array.from(Buffer.from(s, 'base64')))
32
+ }
@@ -0,0 +1,45 @@
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/derive.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Multi-tenant agent derivation — the canonical algorithm shared by every Lyra
3
+ * surface (CLI, gateway, Telegram). Each owner wallet gets ONE deterministic
4
+ * agent keypair, derived from a single server master secret. No per-user key
5
+ * storage; the same owner always resolves to the same agent everywhere.
6
+ *
7
+ * The web mirrors this byte-for-byte in `apps/web/lib/agent-derive.ts` — keep the
8
+ * domain string + HMAC construction identical or the two will diverge.
9
+ *
10
+ * The owner authenticates per interface (SIWS on web, /link on Telegram, local
11
+ * config on CLI); the on-chain `lyra::policy` AgentPolicy(owner, agent) records
12
+ * the binding and bounds what the derived agent may spend.
13
+ */
14
+
15
+ import { createHmac } from 'node:crypto'
16
+ import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'
17
+
18
+ const DERIVATION_DOMAIN = 'lyra-agent:v1:'
19
+
20
+ /** Derive the agent keypair belonging to `ownerAddress`. */
21
+ export function deriveAgentKeypair(
22
+ ownerAddress: string,
23
+ masterSecret: string | undefined = process.env.LYRA_MASTER_SECRET,
24
+ ): Ed25519Keypair {
25
+ if (!masterSecret || masterSecret.length < 32) {
26
+ throw new Error('LYRA_MASTER_SECRET is not configured (need ≥32 chars)')
27
+ }
28
+ const owner = ownerAddress.trim().toLowerCase()
29
+ if (!/^0x[0-9a-f]{1,64}$/.test(owner)) throw new Error(`invalid owner address: ${ownerAddress}`)
30
+ const seed = createHmac('sha256', masterSecret)
31
+ .update(DERIVATION_DOMAIN + owner)
32
+ .digest()
33
+ return Ed25519Keypair.fromSecretKey(new Uint8Array(seed))
34
+ }
35
+
36
+ /** The Sui address of the agent belonging to `ownerAddress`. */
37
+ export function deriveAgentAddress(ownerAddress: string, masterSecret?: string): string {
38
+ return deriveAgentKeypair(ownerAddress, masterSecret).toSuiAddress()
39
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * System-prompt guidance injected when the onchain plugin is active. Tells the
3
+ * brain what the Sui tools do and — critically — that the policy is enforced in
4
+ * code and on-chain, not by the model.
5
+ */
6
+
7
+ export const ONCHAIN_GUIDANCE = `# Sui on-chain tools (Lyra)
8
+
9
+ You operate a single Sui agent address. You can read and move funds ONLY through
10
+ these tools; every value-moving action is checked by a deterministic policy in
11
+ code AND re-enforced on-chain by the lyra::policy Move package. You cannot talk
12
+ your way past a cap — if a transfer exceeds the per-tx cap or budget, the tool
13
+ returns "policy blocked" before anything is signed.
14
+
15
+ Read-only / discovery:
16
+ - account.info — the agent's Sui address, network, balance, and active policy.
17
+ - sui.balance — SUI and coin balances for the agent or any address.
18
+ - policy.show — the active fund-control policy (caps, allowlists, expiry, tier).
19
+ - protocols.list — which protocols Lyra can READ vs EXECUTE on.
20
+ - defi.yields — best yields across ALL Sui protocols (DefiLlama). Each result is
21
+ tagged executable / executeWith.
22
+ - deepbook.markets — DeepBook spot mid prices.
23
+ - cetus.quote — best swap route/price across many DEXes (aggregator, read-only).
24
+ - scallop.markets / scallop.position, navi.markets / navi.position — lending
25
+ rates + the agent's positions.
26
+
27
+ Writes (policy-checked → simulated → executed):
28
+ - policy.create — publish a shared on-chain AgentPolicy (budget, per-tx cap,
29
+ expiry). Do this first to arm on-chain enforcement + receipts.
30
+ - sui.send — transfer SUI. Blocked if out of policy; mints an on-chain receipt.
31
+ - scallop.supply / scallop.withdraw, navi.supply / navi.withdraw — lend or
32
+ redeem idle SUI on Scallop / NAVI (the two largest Sui money markets).
33
+ - walrus.store — persist a receipt/report/memory artifact to Walrus.
34
+
35
+ The capability boundary (important):
36
+ - Discovery is broad; EXECUTION is bounded. Lyra can only act on the protocols in
37
+ protocols.list (currently Scallop + NAVI lending, Sui transfers, Walrus).
38
+ - If the best yield a user wants is on a protocol Lyra has NOT integrated, DO NOT
39
+ invent a transaction. Say so honestly, then offer: (a) the best executable
40
+ alternative (e.g. supply on NAVI/Scallop), or (b) concise manual steps. The
41
+ policy's protocol-allowlist enforces this on-chain too.
42
+
43
+ Rules:
44
+ - Call policy.show / protocols.list before claiming what you can spend or do.
45
+ - When an action is blocked or needs approval, explain why using the policy; do
46
+ not retry to get around it.
47
+ - Prefer storing an execution receipt to Walrus after a successful write.`
package/src/index.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * lyra-plugin-onchain
3
+ *
4
+ * Brain limbs for on-chain operations on Sui:
5
+ *
6
+ * Account: account.info, sui.balance
7
+ * Transfers: sui.send (policy → simulate → execute → on-chain receipt)
8
+ * Policy: policy.show, policy.create (lyra::policy AgentPolicy)
9
+ * Storage: walrus.store (durable, verifiable receipts/memory)
10
+ * Markets: deepbook.markets (DeepBook spot mid prices, read-only)
11
+ *
12
+ * Value-moving tools run through policy → simulate → (approval) → execute, and
13
+ * are re-enforced on-chain by the lyra::policy Move package.
14
+ *
15
+ * Runtime ctx is side-banded onto PluginContext under `.onchain` (see
16
+ * `OnchainRuntimeContext` in `./types.ts`). Without it the plugin registers
17
+ * nothing — a graceful no-op for unit-test loaders.
18
+ */
19
+
20
+ import type { NativePlugin, ToolDef } from 'lyra-core'
21
+ import { makeAccountInfo, makeSuiBalance } from './tools/balance'
22
+ import { makeCetusQuote } from './tools/cetus'
23
+ import { makeDeepbookMarkets } from './tools/deepbook'
24
+ import { makeDefiYields } from './tools/defillama'
25
+ import { makeNaviMarkets, makeNaviPosition, makeNaviSupply, makeNaviWithdraw } from './tools/navi'
26
+ import { makePolicyCreate, makePolicyShow } from './tools/policy'
27
+ import { makeProtocolsList } from './tools/protocols'
28
+ import {
29
+ makeScallopMarkets,
30
+ makeScallopPosition,
31
+ makeScallopSupply,
32
+ makeScallopWithdraw,
33
+ } from './tools/scallop'
34
+ import { makeSuiSend } from './tools/send'
35
+ import { makeSwap } from './tools/swap'
36
+ import { makeWalrusStore } from './tools/walrus'
37
+ import type { OnchainRuntimeContext } from './types'
38
+
39
+ export {
40
+ makeSuiClient,
41
+ keypairFromSecret,
42
+ suiRpcUrl,
43
+ type SuiNetwork,
44
+ } from './client'
45
+ export { simulate, type SimResult } from './simulate'
46
+ export {
47
+ evaluatePolicy,
48
+ policyFromEnv,
49
+ suiToMist,
50
+ normalizeCoinType,
51
+ MIST_PER_SUI,
52
+ type SuiPolicy,
53
+ type SuiPolicyAction,
54
+ type PolicyVerdict,
55
+ } from './policy'
56
+ export { policyRequiresApprovalForCall } from './approval'
57
+ export { deriveAgentKeypair, deriveAgentAddress } from './derive'
58
+ export { resolveOwnerVault, type OwnerVault } from './vault'
59
+ export { ONCHAIN_GUIDANCE } from './guidance'
60
+ export type { OnchainRuntimeContext } from './types'
61
+
62
+ const plugin: NativePlugin = {
63
+ name: 'onchain',
64
+ register: ctx => {
65
+ const onchain = (ctx as unknown as { onchain?: OnchainRuntimeContext }).onchain
66
+ if (!onchain) return // soft-init for tests / non-onchain contexts
67
+
68
+ ctx.registerTool(makeAccountInfo(onchain) as ToolDef)
69
+ ctx.registerTool(makeSuiBalance(onchain) as ToolDef)
70
+ ctx.registerTool(makeSuiSend(onchain) as ToolDef)
71
+ ctx.registerTool(makeSwap(onchain) as ToolDef)
72
+ ctx.registerTool(makePolicyShow(onchain) as ToolDef)
73
+ ctx.registerTool(makePolicyCreate(onchain) as ToolDef)
74
+ ctx.registerTool(makeWalrusStore(onchain) as ToolDef)
75
+ ctx.registerTool(makeDeepbookMarkets(onchain) as ToolDef)
76
+
77
+ // Discovery + capability map.
78
+ ctx.registerTool(makeProtocolsList(onchain) as ToolDef)
79
+ ctx.registerTool(makeDefiYields(onchain) as ToolDef)
80
+ ctx.registerTool(makeCetusQuote(onchain) as ToolDef)
81
+
82
+ // Lending (the two largest Sui money markets).
83
+ ctx.registerTool(makeScallopMarkets(onchain) as ToolDef)
84
+ ctx.registerTool(makeScallopPosition(onchain) as ToolDef)
85
+ ctx.registerTool(makeScallopSupply(onchain) as ToolDef)
86
+ ctx.registerTool(makeScallopWithdraw(onchain) as ToolDef)
87
+ ctx.registerTool(makeNaviMarkets(onchain) as ToolDef)
88
+ ctx.registerTool(makeNaviPosition(onchain) as ToolDef)
89
+ ctx.registerTool(makeNaviSupply(onchain) as ToolDef)
90
+ ctx.registerTool(makeNaviWithdraw(onchain) as ToolDef)
91
+ },
92
+ }
93
+
94
+ export default plugin
@@ -0,0 +1,187 @@
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
+ })