Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,23 @@ actions --json wallet balance --chain base-sepolia

- `actions assets` - configured asset allowlist.
- `actions chains` - configured chain shortnames + IDs.
- `actions lend markets [--asset <symbol>] [--chain <name> | --chain-id <id>]` -
lending markets across configured providers, optionally filtered to one
asset and/or one chain (no wallet).
- `actions lend market --market <name>` - inspect one market by name
(no wallet).
- `actions wallet address` - EOA address derived from `PRIVATE_KEY`.
- `actions wallet balance [--chain <name> | --chain-id <id>]` - balances
per chain + asset; the chain flags are mutually exclusive.
- `actions wallet lend position --market <name>` - the wallet's current
balance and shares in a market.
- `actions wallet lend open --market <name> --amount <n> [--approval-mode <exact|max>]` -
supply assets to a market. `--approval-mode max` approves max-uint to
amortise approvals across future supplies (default: `exact`).
- `actions wallet lend close --market <name> (--amount <n> | --max)` -
withdraw assets. Pass `--max` to withdraw the wallet's full balance in
the market (the CLI fetches the position first; subject to inflight
interest accrual).

## Wallet model

Expand All @@ -44,6 +58,13 @@ demo, fund the EOA with testnet ETH on Base Sepolia.
Both flags accept a comma-separated list to scope the SDK fan-out
to multiple chains. Run `actions --json chains` for the current
list.
- **Markets** - pass the market `name` from the config allowlist
(e.g. `Gauntlet USDC`, `Aave ETH`). Case-insensitive; whitespace
and hyphens are ignored, so `gauntlet-usdc` and `gauntletusdc`
resolve to the same entry. The market entry carries its own chain
and asset, so no `--chain` is needed.
- **Amounts** - human-readable decimal numbers (e.g. `10`, `0.5`).
The SDK converts to wei using the asset's decimals.

## Output

Expand Down Expand Up @@ -76,6 +97,46 @@ To shrink the failure surface, scope the call with `--chain` or
`--chain-id` (both accept a comma-separated list). The SDK only
queries the chains you pass.

## Lend semantics

`wallet lend open` and `wallet lend close` emit a structured envelope
on stdout:

```json
{
"action": "open" | "close",
"market": { "name": "...", "address": "0x...", "chainId": ..., "provider": "..." },
"asset": { "symbol": "..." },
"amount": <number>,
"transactions": [ { "transactionHash": "0x...", "status": "success", ... } ]
}
```

`transactions` is always an array. On EOA the SDK sends approval +
position as two sequential transactions when an approval is required,
so `open` returns 1-2 receipts and `close` returns 1. Bigint receipt
fields (`blockNumber`, `gasUsed`) are stringified.

A receipt with `status: "reverted"` is normalised to a `code: "onchain"`
error envelope on stderr (exit 5), so callers do not need to inspect
receipt status to detect failure.

`wallet lend position` returns the SDK `LendMarketPosition` shape
verbatim: `{ balance, balanceFormatted, shares, sharesFormatted, marketId }`
with bigint fields stringified.

`lend markets` and `lend market` return the SDK `LendMarket` shape(s)
verbatim: `{ marketId, name, asset, supply, apy, metadata }`. These do
not require `PRIVATE_KEY`.

NL -> command examples:

- "what markets can I lend in" -> `actions --json lend markets`
- "supply 10 USDC to Gauntlet" -> `actions --json wallet lend open --market gauntlet-usdc --amount 10`
- "deposit 0.5 ETH into Aave on op-sepolia" -> `actions --json wallet lend open --market aave-eth --amount 0.5`
- "withdraw 5 USDC from Gauntlet" -> `actions --json wallet lend close --market gauntlet-usdc --amount 5`
- "how much do I have in Gauntlet" -> `actions --json wallet lend position --market gauntlet-usdc`

## RPC trust

`*_RPC_URL` env vars must point to operator-trusted endpoints. A
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/__tests__/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,46 @@ describe('actions CLI (built binary)', () => {
const body = JSON.parse(stderr)
expect(body.code).toBe('validation')
})

it('unknown --market on lend open -> stderr JSON code:validation exit 2', async () => {
const { stderr, code } = await run(
[
'--json',
'wallet',
'lend',
'open',
'--market',
'no-such-market',
'--amount',
'1',
],
{ PRIVATE_KEY: ANVIL_ACCOUNT_0 },
)
expect(code).toBe(2)
const body = JSON.parse(stderr)
expect(body.code).toBe('validation')
expect(body.error).toMatch(/Unknown market/)
})

it('non-positive --amount on lend close -> stderr JSON code:validation exit 2', async () => {
const { stderr, code } = await run(
[
'--json',
'wallet',
'lend',
'close',
'--market',
'aave-eth',
'--amount',
'0',
],
{ PRIVATE_KEY: ANVIL_ACCOUNT_0 },
)
expect(code).toBe(2)
const body = JSON.parse(stderr)
expect(body.code).toBe('validation')
expect(body.error).toMatch(/Invalid --amount/)
})
})

describe('default (human) mode', () => {
Expand Down
81 changes: 81 additions & 0 deletions packages/cli/src/commands/__tests__/lendMarket.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { MockInstance } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { runLendMarket } from '@/commands/lend/market.js'
import * as baseCtx from '@/context/baseContext.js'
import { getDemoConfig } from '@/demo/config.js'
import { CliError } from '@/output/errors.js'
import { setJsonMode } from '@/output/mode.js'

beforeEach(() => setJsonMode(true))
afterEach(() => setJsonMode(false))

describe('runLendMarket', () => {
let writeSpy: MockInstance

beforeEach(() => {
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
})

afterEach(() => {
vi.restoreAllMocks()
})

const mockActions = (getMarket: (params: unknown) => Promise<unknown>) => {
vi.spyOn(baseCtx, 'baseContext').mockReturnValue({
config: getDemoConfig(),
actions: { lend: { getMarket } } as never,
})
}

it('routes by resolved market and emits the SDK shape verbatim', async () => {
const captured: unknown[] = []
mockActions(async (params) => {
captured.push(params)
return {
marketId: params,
name: 'Gauntlet USDC',
asset: { metadata: { symbol: 'USDC_DEMO' } },
supply: { totalAssets: 42n, totalShares: 41n },
apy: {
total: 0.05,
native: 0.04,
totalRewards: 0.01,
performanceFee: 0.1,
},
metadata: { owner: '0x', curator: '0x', fee: 0, lastUpdate: 0 },
}
})
await runLendMarket({ market: 'gauntlet-usdc' })
const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0]))
expect(body.name).toBe('Gauntlet USDC')
expect(body.supply.totalAssets).toBe('42')
expect(captured).toHaveLength(1)
const call = captured[0] as { address: string; chainId: number }
expect(call.chainId).toBe(84532)
})

it('rejects unknown markets with CliError(validation)', async () => {
mockActions(async () => ({}))
try {
await runLendMarket({ market: 'no-such-market' })
throw new Error('did not throw')
} catch (err) {
expect(err).toBeInstanceOf(CliError)
expect((err as CliError).code).toBe('validation')
}
})

it('maps RPC failures to CliError(network)', async () => {
mockActions(async () => {
throw new Error('fetch failed')
})
try {
await runLendMarket({ market: 'gauntlet-usdc' })
throw new Error('did not throw')
} catch (err) {
expect(err).toBeInstanceOf(CliError)
expect((err as CliError).code).toBe('network')
}
})
})
100 changes: 100 additions & 0 deletions packages/cli/src/commands/__tests__/lendMarkets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { MockInstance } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { runLendMarkets } from '@/commands/lend/markets.js'
import * as baseCtx from '@/context/baseContext.js'
import { CliError } from '@/output/errors.js'
import { setJsonMode } from '@/output/mode.js'

beforeEach(() => setJsonMode(true))
afterEach(() => setJsonMode(false))

describe('runLendMarkets', () => {
let writeSpy: MockInstance

beforeEach(() => {
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
})

afterEach(() => {
vi.restoreAllMocks()
})

const mockActions = (getMarkets: () => Promise<unknown>) => {
vi.spyOn(baseCtx, 'baseContext').mockReturnValue({
config: { chains: [{ chainId: 84532 }, { chainId: 11155420 }] } as never,
actions: { lend: { getMarkets } } as never,
})
}

it('emits the array of markets with bigints stringified', async () => {
mockActions(async () => [
{
marketId: { address: '0xabc', chainId: 84532 },
name: 'Gauntlet USDC',
asset: { metadata: { symbol: 'USDC_DEMO' } },
supply: { totalAssets: 1000000n, totalShares: 999999n },
apy: {
total: 0.05,
native: 0.04,
totalRewards: 0.01,
performanceFee: 0.1,
},
metadata: {
owner: '0xowner',
curator: '0xcurator',
fee: 100,
lastUpdate: 0,
},
},
])
await runLendMarkets()
const body = JSON.parse(String(writeSpy.mock.calls[0]?.[0]))
expect(body).toHaveLength(1)
expect(body[0].name).toBe('Gauntlet USDC')
expect(body[0].supply.totalAssets).toBe('1000000')
expect(body[0].marketId.chainId).toBe(84532)
})

it('maps RPC failures to CliError(network)', async () => {
mockActions(async () => {
throw new Error('HTTP request failed. Status: ECONNREFUSED')
})
try {
await runLendMarkets()
throw new Error('did not throw')
} catch (err) {
expect(err).toBeInstanceOf(CliError)
expect((err as CliError).code).toBe('network')
expect((err as CliError).retryable).toBe(true)
}
})

it('forwards --chain to the SDK as chainId', async () => {
const getMarkets = vi.fn(async () => [])
vi.spyOn(baseCtx, 'baseContext').mockReturnValue({
config: {
chains: [{ chainId: 84532 }, { chainId: 11155420 }],
assets: { allow: [] },
} as never,
actions: { lend: { getMarkets } } as never,
})
await runLendMarkets({ chain: 'base-sepolia' })
expect(getMarkets).toHaveBeenCalledWith({
asset: undefined,
chainId: 84532,
})
})

it('rejects multi-chain --chain values with CliError(validation)', async () => {
mockActions(async () => [])
try {
await runLendMarkets({ chain: 'base-sepolia,op-sepolia' })
throw new Error('did not throw')
} catch (err) {
expect(err).toBeInstanceOf(CliError)
expect((err as CliError).code).toBe('validation')
expect((err as CliError).message).toMatch(/single chain/)
}
})
})
Loading