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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ Read-only. Supports `--x402` and `--mpp` for pay-per-call.
|---------|-------------|---------|
| `zerion analyze <address\|ens>` | Full analysis — portfolio, positions, transactions, PnL in parallel | `zerion analyze vitalik.eth` |
| `zerion portfolio <address\|ens>` | Portfolio value and top positions | `zerion portfolio 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045` |
| `zerion positions <address\|ens>` | Token + DeFi positions (`--positions all\|simple\|defi`) | `zerion positions vitalik.eth --positions defi` |
| `zerion positions <address\|ens>` | Token + DeFi positions (`--positions all\|simple\|defi`, or `--defi` for grouped-by-protocol view with loans netted) | `zerion positions vitalik.eth --defi` |
| `zerion history <address\|ens>` | Transaction history (`--limit`, `--chain`) | `zerion history vitalik.eth --limit 10 --chain ethereum` |
| `zerion pnl <address\|ens>` | Profit & loss (realized, unrealized, fees) | `zerion pnl vitalik.eth` |
| `zerion search <query>` | Search tokens by name or symbol | `zerion search USDC` |
Expand Down Expand Up @@ -330,6 +330,7 @@ Track wallets by name without exposing addresses in commands.
| `--to-wallet <name>` | Destination wallet for `bridge` (Solana ↔ EVM) |
| `--to-address <addr>` | Destination address for `bridge` (must match destination-chain format) |
| `--positions all\|simple\|defi` | Filter positions type |
| `--defi` | On `positions`: shorthand for `--positions defi` with output grouped by protocol (LP tokens pooled by `group_id`, loans netted in `net_value`) |
| `--limit <n>` | Limit results (default: 20 for list ops) |
| `--offset <n>` | Skip first N results (pagination) |
| `--search <query>` | Filter wallets by name or address |
Expand Down
132 changes: 130 additions & 2 deletions cli/commands/analytics/positions.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
/**
* wallet positions — token holdings and DeFi positions with filtering.
* Supports --positions all|simple|defi and --chain filtering.
* Supports --positions all|simple|defi, --defi shorthand, and --chain filtering.
*
* --defi enables a richer DeFi-aware response that groups positions by dapp
* (Aave, Uniswap, Lido, etc.) and collapses LP tokens that share a `group_id`
* into a single pool entry. Loans are netted against deposits in the protocol
* total (loan value enters the sum negatively).
*/

import * as api from "../../utils/api/client.js";
import { print, printError } from "../../utils/common/output.js";
import { resolveAddressOrWallet } from "../../utils/wallet/resolve.js";
import { validateChain, validatePositions, resolvePositionFilter } from "../../utils/common/validate.js";
import { resolveAuth } from "../../utils/api/auth.js";
import { formatPositions } from "../../utils/common/format.js";
import { formatPositions, formatDefiPositions } from "../../utils/common/format.js";

export default async function walletPositions(args, flags) {
const chainErr = validateChain(flags.chain);
Expand All @@ -17,6 +22,18 @@ export default async function walletPositions(args, flags) {
process.exit(1);
}

// --defi is shorthand for --positions defi + DeFi-grouped output.
// Conflict only if user also passed an incompatible --positions value.
const defiMode = !!flags.defi;
if (defiMode && flags.positions && flags.positions !== "defi") {
printError(
"conflicting_flags",
`--defi cannot be combined with --positions ${flags.positions}. Use one or the other.`,
);
process.exit(1);
}
if (defiMode) flags.positions = "defi";

const posErr = validatePositions(flags.positions);
if (posErr) {
printError(posErr.code, posErr.message, { supportedValues: posErr.supportedValues });
Expand All @@ -33,6 +50,26 @@ export default async function walletPositions(args, flags) {
auth,
});

if (defiMode) {
const enriched = (response.data || [])
.map(toDefiPosition)
.filter((p) => p.value > 0);
const protocols = groupByDapp(enriched);
print({
wallet: { name: walletName, address },
filter: "defi",
chain: flags.chain ?? null,
summary: {
total_value: netValue(enriched),
gross_value: enriched.reduce((s, p) => s + (p.value || 0), 0),
protocols: protocols.length,
positions: enriched.length,
},
protocols,
}, formatDefiPositions);
return;
}

const positions = (response.data || [])
.map((p) => ({
name: p.attributes.fungible_info?.name ?? p.attributes.name ?? "Unknown",
Expand All @@ -58,3 +95,94 @@ export default async function walletPositions(args, flags) {
process.exit(1);
}
}

function toDefiPosition(p) {
const a = p.attributes || {};
return {
name: a.fungible_info?.name ?? a.name ?? "Unknown",
symbol: a.fungible_info?.symbol ?? null,
chain: p.relationships?.chain?.data?.id ?? null,
quantity: a.quantity?.float ?? null,
value: a.value ?? 0,
price: a.price ?? null,
change_percent_1d: a.changes?.percent_1d ?? null,
protocol: a.protocol ?? null,
protocol_module: a.protocol_module ?? null,
position_type: a.position_type ?? null,
group_id: a.group_id ?? null,
pool_address: a.pool_address ?? null,
dapp: {
id: p.relationships?.dapp?.data?.id ?? null,
name: a.application_metadata?.name ?? null,
url: a.application_metadata?.url ?? null,
},
};
}

// Sign a position's value: loans are debt, everything else is asset.
function signedValue(p) {
return p.position_type === "loan" ? -p.value : p.value;
}

function netValue(positions) {
return positions.reduce((sum, p) => sum + signedValue(p), 0);
}

// Group flat positions into protocol → group_id → tokens. The API returns one
// row per token even within a single Uniswap pool, so positions that share
// `group_id` belong to the same pool and should render together.
function groupByDapp(positions) {
const byDapp = new Map();
for (const p of positions) {
const dappKey = p.dapp?.name || p.protocol || p.protocol_module || "Other";
if (!byDapp.has(dappKey)) {
byDapp.set(dappKey, {
dapp: dappKey,
dapp_url: p.dapp?.url ?? null,
module: p.protocol_module ?? null,
net_value: 0,
gross_value: 0,
groups: new Map(),
});
}
const entry = byDapp.get(dappKey);
entry.net_value += signedValue(p);
entry.gross_value += p.value || 0;

// Pool/group rollup: tokens sharing a group_id render as one pool. Tokens
// without a group_id each get their own synthetic key so they render flat.
const groupKey = p.group_id ? `g:${p.group_id}` : `t:${entry.groups.size}:${p.symbol}`;
if (!entry.groups.has(groupKey)) {
entry.groups.set(groupKey, {
group_id: p.group_id ?? null,
position_type: p.position_type ?? null,
pool_address: p.pool_address ?? null,
value: 0,
tokens: [],
});
}
const g = entry.groups.get(groupKey);
g.value += p.value || 0;
g.tokens.push({
symbol: p.symbol,
name: p.name,
chain: p.chain,
quantity: p.quantity,
value: p.value,
price: p.price,
change_percent_1d: p.change_percent_1d,
position_type: p.position_type,
});
}
// Flatten Maps to arrays sorted by value desc.
return [...byDapp.values()]
.map((d) => ({
dapp: d.dapp,
dapp_url: d.dapp_url,
module: d.module,
net_value: d.net_value,
gross_value: d.gross_value,
groups: [...d.groups.values()].sort((a, b) => b.value - a.value),
}))
.sort((a, b) => b.gross_value - a.gross_value);
}
3 changes: 2 additions & 1 deletion cli/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function printUsage() {
analysis: {
"analyze <address|name>": "Full analysis (portfolio, positions, txs, PnL in parallel)",
"portfolio <address|name>": "Portfolio value and top positions",
"positions <address|name>": "Token + DeFi positions (--positions all|simple|defi)",
"positions <address|name>": "Token + DeFi positions (--positions all|simple|defi, or --defi for grouped DeFi view)",
"history <address|name>": "Transaction history (--limit <n>, --chain <chain>)",
"pnl <address|name>": "Profit & loss (realized, unrealized, fees)",
},
Expand Down Expand Up @@ -90,6 +90,7 @@ function printUsage() {
"--to-wallet <name>": "Destination wallet for bridge (Solana ↔ EVM)",
"--to-address <addr>": "Destination address for bridge (must match destination-chain format)",
"--positions all|simple|defi": "Filter positions type",
"--defi": "Shorthand for --positions defi with grouped-by-protocol output (loans netted, LP tokens pooled)",
"--limit <n>": "Limit results (transactions, wallet list; default: 20 for list)",
"--offset <n>": "Skip first N results (pagination for wallet list)",
"--search <query>": "Filter wallets by name or address",
Expand Down
105 changes: 104 additions & 1 deletion cli/tests/unit/cli/utils/common/format.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { formatBridgeOffers } from "#zerion/utils/common/format.js";
import { formatBridgeOffers, formatDefiPositions } from "#zerion/utils/common/format.js";

const SAMPLE = {
fromChain: "base",
Expand Down Expand Up @@ -129,3 +129,106 @@ describe("formatBridgeOffers", () => {
assert.match(out, /no offers/);
});
});

const DEFI_SAMPLE = {
wallet: { name: "main", address: "0xabc1234567890" },
filter: "defi",
chain: null,
summary: { total_value: 4200, gross_value: 4500, protocols: 2, positions: 4 },
protocols: [
{
dapp: "Aave V3",
dapp_url: "https://app.aave.com/",
module: "lending",
net_value: 3700,
gross_value: 4000,
groups: [
{
group_id: null,
position_type: "deposit",
pool_address: null,
value: 4000,
tokens: [
{ symbol: "USDC", chain: "ethereum", quantity: 4000, value: 4000, position_type: "deposit" },
],
},
{
group_id: null,
position_type: "loan",
pool_address: null,
value: 300,
tokens: [
{ symbol: "DAI", chain: "ethereum", quantity: 300, value: 300, position_type: "loan" },
],
},
],
},
{
dapp: "Uniswap V3",
dapp_url: null,
module: "liquidity_pool",
net_value: 500,
gross_value: 500,
groups: [
{
group_id: "0a771a0064dad468045899032c7fb01a971f973f7dff0a5cdc3ce199f45e94d7",
position_type: "deposit",
pool_address: "0x109830a1aaad605bbf02a9dfa7b0b92ec2fb7daa",
value: 500,
tokens: [
{ symbol: "WETH", chain: "ethereum", quantity: 0.1, value: 250, position_type: "deposit" },
{ symbol: "USDC", chain: "ethereum", quantity: 250, value: 250, position_type: "deposit" },
],
},
],
},
],
};

describe("formatDefiPositions", () => {
it("renders header with protocol count and net total", () => {
const out = formatDefiPositions(DEFI_SAMPLE);
assert.match(out, /DeFi Positions/);
assert.match(out, /2 protocols/);
assert.match(out, /4 positions/);
assert.match(out, /\$4,200\.00/);
});

it("groups rows under each dapp with its module label", () => {
const out = formatDefiPositions(DEFI_SAMPLE);
assert.match(out, /Aave V3/);
assert.match(out, /\[lending\]/);
assert.match(out, /Uniswap V3/);
assert.match(out, /\[liquidity_pool\]/);
});

it("shows position_type badges (deposit / loan)", () => {
const out = formatDefiPositions(DEFI_SAMPLE);
assert.match(out, /\[deposit/);
assert.match(out, /\[loan/);
});

it("renders loan values as negative", () => {
const out = formatDefiPositions(DEFI_SAMPLE);
// Loan row should carry a leading '-' on its value.
assert.match(out, /-\$300\.00/);
});

it("collapses LP tokens that share a group_id into a single pool header", () => {
const out = formatDefiPositions(DEFI_SAMPLE);
assert.match(out, /Pool 0a771a0064…/);
// Both pool tokens still appear under it.
assert.match(out, /WETH/);
assert.match(out, /USDC/);
});

it("handles empty protocols list", () => {
const out = formatDefiPositions({
...DEFI_SAMPLE,
summary: { total_value: 0, gross_value: 0, protocols: 0, positions: 0 },
protocols: [],
});
assert.match(out, /DeFi Positions/);
assert.match(out, /no DeFi positions/);
});
});
59 changes: 59 additions & 0 deletions cli/utils/common/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,65 @@ export function formatPositions(data) {
return lines.join("\n");
}

// Color tags per position_type. Picks reflect financial polarity:
// loan = debt (red), reward = pending income (yellow), locked = illiquid (cyan),
// deposit/staked = active asset (green). wallet/investment fall through to dim.
const POSITION_TYPE_COLOR = {
deposit: GREEN,
staked: GREEN,
loan: RED,
reward: YELLOW,
locked: CYAN,
};

function positionTypeBadge(type) {
const color = POSITION_TYPE_COLOR[type] || DIM;
const label = (type || "—").padEnd(8);
return `${color}[${label}]${RESET}`;
}

// Format a position value with loan-aware sign + color. Loans display as
// negative because their value represents outstanding debt, not asset value.
function signedUsd(value, positionType) {
if (positionType === "loan") return `${RED}-${usd(value)}${RESET}`;
return usd(value);
}

export function formatDefiPositions(data) {
const walletLabel = data.wallet.name || data.wallet.address.slice(0, 10) + "...";
const { protocols, positions } = data.summary;
const lines = [
`${BOLD}DeFi Positions${RESET} — ${walletLabel} ${DIM}(${protocols} protocols · ${positions} positions · net ${usd(data.summary.total_value)})${RESET}\n`,
];

if (!data.protocols.length) {
lines.push(` ${DIM}(no DeFi positions found)${RESET}`);
return lines.join("\n");
}

for (const proto of data.protocols) {
const moduleLabel = proto.module ? ` ${DIM}[${proto.module}]${RESET}` : "";
lines.push(`${BOLD}${proto.dapp}${RESET}${moduleLabel} ${DIM}net${RESET} ${BOLD}${usd(proto.net_value)}${RESET}`);
lines.push(` ${DIM}${"─".repeat(72)}${RESET}`);
for (const g of proto.groups) {
const isPool = g.tokens.length > 1 && g.group_id;
if (isPool) {
lines.push(` ${DIM}Pool ${g.group_id.slice(0, 10)}…${RESET} ${padStart(usd(g.value), 14)}`);
for (const t of g.tokens) lines.push(renderDefiRow(t, true));
} else {
for (const t of g.tokens) lines.push(renderDefiRow(t, false));
}
}
lines.push("");
}
return lines.join("\n").trimEnd();
}

function renderDefiRow(t, indented) {
const indent = indented ? " " : " ";
return `${indent}${positionTypeBadge(t.position_type)} ${pad(t.symbol || "?", 10)} ${pad(t.chain || "?", 12)} ${padStart(t.quantity != null ? Number(t.quantity).toFixed(4) : "-", 14)} ${padStart(signedUsd(t.value, t.position_type), 18)}`;
}

function formatChange(position) {
if (position.change_percent_1d == null) {
return `${DIM}-${RESET}`;
Expand Down
Loading
Loading