Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Changelog

All notable changes to the JustLend CLI are documented here.

## [Unreleased]

### Added

- Added public payer history under `energy purchase history` for in-progress and settled direct-purchase orders.

### Fixed

- Applied the mainnet-only production-host guard to the `buy` path, including dry runs and explicit production URL overrides.
- Kept replayable payment recovery state until public history confirms tokenless idempotent orders, and documented the exact signed-request persistence boundary.

## [1.0.1] - 2026-08-19

### Added

- Versioned JSON success/error envelopes with `schemaVersion`, stable `code`, and explicit `retryable` fields.
- Published JSON Schema at `schemas/output-v1.schema.json` for agent and CI validation.
- Process-level regression tests for successful commands, unknown commands, and invalid option values.

### Fixed

- Detect `--json` before Commander parsing so usage errors emit one valid JSON object instead of human text or multiple fragments.
- Route daemon and placeholder JSON paths through the shared success envelope.

## [1.0.0]

- Initial source release covering JustLend V1/V2 reads and writes, staking, energy rental, governance, mining, dry-run simulation, and TronLink signing.
64 changes: 58 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
[![Protocol: JustLend DAO](https://img.shields.io/badge/Protocol-JustLend_DAO-green)](https://justlend.org/)
[![CI](https://github.com/justlend/justlend-cli/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/justlend/justlend-cli/actions/workflows/ci.yml)

CLI for [JustLend DAO](https://justlend.org) on TRON. Covers V2 (Moolah) lending, V1 legacy lending, sTRX / stUSDT staking, energy rental, governance, rewards, airdrops, mining reads, historical records, pre-sign transaction prechecks, and safe dry-run simulation.
CLI for [JustLend DAO](https://justlend.org) on TRON. Covers V2 (Moolah) lending, V1 legacy lending, sTRX / stUSDT staking, energy rental and direct purchase, governance, rewards, airdrops, mining reads, historical records, pre-sign transaction prechecks, and safe dry-run simulation.

> Current status: active CLI implementation with read paths, selected write paths, TronLink signer integration, JSON output, and dry-run simulation. Production/mainnet validation is not required for QA pass criteria; Nile/testnet dry-run is the default safety regression path.

Expand Down Expand Up @@ -55,7 +55,8 @@ npm run test:smoke:nile
| `--full-host <url>` | network default | Override Tron RPC host; env: `JUSTLEND_FULL_HOST`. |
| `--api-host <url>` | network default | Override JustLend V1 backend host; env: `JUSTLEND_API_HOST`. |
| `--moolah-api-host <url>` | network default | Override V2 Moolah backend host; env: `JUSTLEND_MOOLAH_API_HOST`. |
| `--json` | off | Machine-readable output: `{success,data}` or `{success:false,error}`. |
| `--energy-api-url <url>` | official production API | Override the Energy direct-purchase API; env: `JUSTLEND_ENERGY_API_URL`. |
| `--json` | off | Versioned machine-readable output; success on stdout, one structured error on stderr. |
| `--local-broadcast` | off | Broadcast via CLI local TronWeb instead of signer TronWeb. |
| `--no-broadcast` | off | Sign only; return `signedTx` without sending. |
| `--dry-run` | off | Build calldata and run `triggerconstantcontract` simulation. No signer, no broadcast. |
Expand Down Expand Up @@ -97,7 +98,7 @@ wtrx Wrap native TRX into WTRX or unwrap WTRX back to TRX
airdrop V2 airdrop multiClaim commands
sun SUN liquidity mining pool commands
strx sTRX liquid staking commands
energy Energy rental commands
energy Energy rental and direct-purchase commands
gov Governance commands
mining V2 Moolah mining reward commands
approve Approve TRC20 token spending
Expand All @@ -122,6 +123,51 @@ rewards V1 mining + V2 airdrop claimable summary
| 🔴 Destructive / high-risk | `liquidate` | seizes another account's collateral (irreversible); dry-run + explicit `--yes`, never automate without review |
| ⚙️ Daemon (local) | `serve` | per-session token + `0600/0700` files; same-user only; idle auto-shutdown (`--idle-timeout`, default 10 min) |

### Energy direct purchase

The purchase API is separately deployed. The CLI uses the same official production endpoint as the
app release by default: `https://tegrow.ablesdxd.link`. Limits, durations, prices, payment address,
and pool capacity remain live backend data; no economic values are hard-coded. A custom/test endpoint
requires an explicit URL and the standard untrusted-host opt-in.

```bash
export JUSTLEND_ENERGY_API_URL="https://energy-api.example" # optional override
export JUSTLEND_ALLOW_UNTRUSTED_HOSTS=1 # temporary/custom endpoints only

# Read live backend limits, prices, and pool capacity
justlend energy purchase config

# Read-only authoritative quote
justlend energy purchase quote 65000 --receiver TReceiverAddress...

# Quote-only dry run: no wallet and no signed transaction
justlend --dry-run energy purchase buy 65000 --receiver TReceiverAddress...

# Explicit write: prompts before signing; --yes is required for non-TTY/JSON use
justlend --yes energy purchase buy 65000 --receiver TReceiverAddress...

# Reconcile a payment whose submission result was unknown
justlend energy purchase risk TPayerAddress...

# Read public purchase history; add --page/--size for server pagination
justlend energy purchase history TPayerAddress...
```

The CLI signs a native TRX transfer but **never broadcasts it locally**. The configured energy
service validates and broadcasts the signed transaction. Ambiguous submissions retry only the same
signed transaction. For ambiguous submissions, the exact signed request (including the signature and
raw transaction) is persisted in the local mode-`0600`
`~/.justlend-cli/energy-payment-risks.json` file. It remains broadcastable until transaction expiry,
is redacted from normal command output, and is removed only after public purchase history confirms the
payment/order or the backend deterministically rejects it before broadcast. This prevents a later
invocation from silently creating a second payment. A per-payer intent lock is also created atomically before signing, so concurrent CLI
processes cannot authorize two payments. The final authoritative quote must exactly match the amount
shown at confirmation time. Corrupt or unreadable safety state blocks purchases instead of being
treated as empty. Risk output distinguishes FullNode `observed`/`included` status from SolidityNode
`solidified` finality; an RPC error or missing transaction remains unresolved and cannot authorize a
new signature. `--no-broadcast` is intentionally rejected for this workflow; use `quote` or
`--dry-run` instead.

## Safe dry-run examples

Dry-run is the recommended way to test write paths. It does not sign and does not broadcast.
Expand Down Expand Up @@ -209,28 +255,34 @@ Success:

```json
{
"schemaVersion": "1.0.0",
"success": true,
"data": {}
}
```

Failure:
Failure (written as exactly one JSON object to stderr, including parser/usage failures):

```json
{
"schemaVersion": "1.0.0",
"success": false,
"error": "message"
"error": "unknown command 'example'",
"code": "CLI_USAGE_ERROR",
"retryable": false,
"hint": "Run `justlend --help` or `justlend <command> --help` and correct the arguments."
}
```

Failures may include additional diagnostic fields such as `code`, `module`, `network`, `host`, `path`, `status`, and `hint`.
The machine-readable JSON Schema is [`schemas/output-v1.schema.json`](./schemas/output-v1.schema.json). Consumers should pin the `schemaVersion` **major**: additive fields may appear within v1, while a removal, rename, or semantic break requires v2. Failures may include diagnostic fields such as `module`, `network`, `host`, `path`, `status`, and `hint`.

### Error / exit code contract

Branch on the **exit code** first (`0` = success, non-zero = failure), then on the JSON `code` field.

| `code` | Meaning | Retryable | How to handle |
|--------|---------|:---:|---------------|
| `CLI_USAGE_ERROR` | Unknown command, option, or invalid argument | ❌ | Correct arguments using `--help`; never retry unchanged |
| `USER_CANCELLED` | Rejected/cancelled in TronLink | ❌ | Re-approve in wallet |
| `SIGNER_TIMEOUT` | TronLink approval timed out | ⚠️ (user must be present) | Retry, approve promptly |
| `SIGNER_DISCONNECTED` | Signer page closed / IPC dropped | ⚠️ after reconnect | Keep the TronLink signer page open, retry |
Expand Down
18 changes: 15 additions & 3 deletions bin/cli.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
#!/usr/bin/env node
import { CommanderError } from 'commander';
import { createProgram } from '../src/index.js';
import { handleError } from '../src/lib/error.js';

async function main(): Promise<void> {
const program = createProgram();
await program.parseAsync(process.argv);
const argv = process.argv.slice(2);
const program = createProgram(argv);
try {
await program.parseAsync(process.argv);
} catch (error) {
if (
error instanceof CommanderError &&
(error.code === 'commander.helpDisplayed' || error.code === 'commander.version')
) {
return;
}
handleError(error);
}
}

main().catch(handleError);
void main();
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 11 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
{
"name": "@justlend/justlend-cli",
"version": "1.0.0",
"version": "1.0.1",
"description": "CLI for JustLend DAO on TRON — V1 + V2 (Moolah) lending, sTRX/stUSDT staking, energy rental, governance",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"import": "./dist/src/index.js"
}
},
"bin": {
"justlend": "dist/bin/cli.js"
},
"files": [
"dist/",
"bin/",
"schemas/",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"keywords": [
Expand Down
36 changes: 36 additions & 0 deletions schemas/output-v1.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/justlend/justlend-cli/blob/main/schemas/output-v1.schema.json",
"title": "JustLend CLI JSON output v1",
"description": "Discriminated success/error envelope emitted by justlend --json. Pin schemaVersion major 1.",
"oneOf": [
{
"type": "object",
"required": ["schemaVersion", "success", "data"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "1.0.0" },
"success": { "const": true },
"data": {}
}
},
{
"type": "object",
"required": ["schemaVersion", "success", "error", "code", "retryable"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "1.0.0" },
"success": { "const": false },
"error": { "type": "string", "minLength": 1 },
"code": { "type": "string", "minLength": 1 },
"retryable": { "type": "boolean" },
"module": { "type": "string" },
"network": { "type": "string" },
"host": { "type": "string" },
"path": { "type": "string" },
"status": { "type": "integer" },
"hint": { "type": "string" }
}
}
]
}
3 changes: 2 additions & 1 deletion src/commands/_stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* Real impl ports MCP services as planned in development documentation.
*/
import type { Command } from 'commander';
import { emitJson } from '../lib/output.js';

interface Stub {
/** Command path. Use space-separated tokens; first token is the (sub)command name. */
Expand Down Expand Up @@ -244,7 +245,7 @@ function registerLeaf(parent: Command, spec: string, stub: Stub): void {
args: passed,
};
if (opts.json) {
process.stdout.write(JSON.stringify(payload) + '\n');
emitJson(payload);
} else {
process.stdout.write(`[${stub.phase}] ${stub.cmd} — not yet implemented (args: ${JSON.stringify(passed)})\n`);
}
Expand Down
2 changes: 2 additions & 0 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const CONFIG_KEYS = [
'fullHost',
'apiHost',
'moolahApiHost',
'energyApiHost',
'apiKey',
] as const;

Expand All @@ -17,6 +18,7 @@ function envName(key: ConfigKey): string {
case 'fullHost': return 'JUSTLEND_FULL_HOST';
case 'apiHost': return 'JUSTLEND_API_HOST';
case 'moolahApiHost': return 'JUSTLEND_MOOLAH_API_HOST';
case 'energyApiHost': return 'JUSTLEND_ENERGY_API_URL';
case 'apiKey': return 'JUSTLEND_API_KEY';
}
}
Expand Down
10 changes: 6 additions & 4 deletions src/commands/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { TronSigner } from 'tronlink-signer';
import { acquireServeLock, clearServeState, getServeDir, startIPCServer, writeServeState } from '../lib/ipc.js';
import { dispatchSignerCall, initSigner, resolveSignerTimeout, shutdownSigner } from '../lib/signer.js';
import { getNetworkFromCommand } from '../lib/command-utils.js';
import { outputResult } from '../lib/output.js';
import { emitJson, outputResult } from '../lib/output.js';

export function registerDaemonCommands(program: Command): void {
program
Expand Down Expand Up @@ -40,14 +40,16 @@ export function registerDaemonCommands(program: Command): void {
const signer = new TronSigner();
await signer.start();
const port = signer.getConfig().httpPort;
writeServeState(port);
// Keep the authentication authority in daemon memory. serve.json is only
// client discovery state and deleting/corrupting it must never disable IPC auth.
const ipcToken = writeServeState(port);

// Activity tracking for idle auto-shutdown. A long-running call (e.g. a
// signature awaiting browser approval) is held open by inFlightRequests,
// so the daemon never shuts down mid-operation.
let lastActivityAt = Date.now();
let inFlightRequests = 0;
const server = await startIPCServer(async (method, params, signal) => {
const server = await startIPCServer(ipcToken, async (method, params, signal) => {
lastActivityAt = Date.now();
inFlightRequests++;
try {
Expand All @@ -59,7 +61,7 @@ export function registerDaemonCommands(program: Command): void {
});

if (opts.json) {
process.stdout.write(JSON.stringify({ status: 'running', pid: process.pid, port, dir: getServeDir() }) + '\n');
emitJson({ status: 'running', pid: process.pid, port, dir: getServeDir() });
} else {
process.stdout.write(`justlend signer daemon running (pid ${process.pid}, port ${port})\nState: ${getServeDir()}\n`);
}
Expand Down
Loading
Loading