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
103 changes: 103 additions & 0 deletions docs/LpPositionManagement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# LP Position Management (Portfolio → Liquidity)

The **Liquidity** tab on `/profile` (and `/profile/:wallet`, read-only) lets a
liquidity provider see their active LP positions and manage them (#1030).

## What it shows

- **Total LP earnings** card: unclaimed rewards + rewards already claimed from
the active positions, plus a position count.
- One row per active position: pool / key name, contributed amount, pool
share %, accrued (unclaimed) rewards, and lock status.
- Per-position actions, rendered only when the connected Stellar signer owns
the positions: **Add liquidity**, **Claim rewards**, **Remove liquidity**.

## Where the data comes from

| Concern | Source | Code |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
| Positions, rewards, lock | `GET /lp/positions?wallet=` (accesslayer-server #980) | `services/lpPositions.service.ts` |
| Pool size, APR estimate | `GET /lp/pool/:keyId` (accesslayer-server #980) | `services/lpPositions.service.ts` |
| Spendable XLM | Account ledger entry over Soroban RPC (`VITE_STELLAR_RPC_URL`) | `services/stellarAccount.service.ts` |
| Add / claim / remove | Creator Keys contract `add_liquidity` / `claim_lp_rewards` / `remove_liquidity` (accesslayer-contracts #1002, PR #1004) | `services/lpContract.service.ts` |

Hooks live in `hooks/useLpPositions.ts`; pure math lives in
`utils/lpPositions.utils.ts`.

### Expected API shapes

The server endpoints are specified in accesslayer-server #980 but were not
merged when this UI was built. The client expects the following shape and
validates every field. Amounts are **stroops as integer strings** (i128 in
the contract); numbers are accepted only when they are safe integers.

```jsonc
// GET /lp/positions?wallet=G... → APIResponse<{ positions: [...] }> (a bare array also works)
{
"lpId": "7", // contract lp_id (u64)
"keyId": "C...", // contract key_id (Address)
"keyName": "Alpha Key", // optional, falls back to a shortened keyId
"creatorId": "alpha", // optional, enables the /creator/:id link
"contribution": "250000000",
"share": 2500, // optional, contract basis points
"poolTotalLiquidity": "1000000000", // optional, preferred for an exact share
"pendingRewards": "12345", // missing/invalid → shown as "Unavailable"
"claimedRewards": "100", // optional, lifetime claimed from this position
"unlocksAt": "2030-01-01T00:00:00Z", // optional; ISO, epoch s, or epoch ms
}

// GET /lp/pool/:keyId → APIResponse<{ keyId, totalLiquidity, aprBps }>
```

Records missing `lpId`, `keyId`, or a valid `contribution` are dropped, as are
closed positions (`contribution = 0`, which is how the contract reports them).

## Rules the UI follows

- **Precision.** All amounts are `bigint` stroops end to end and are rendered
with 7 decimals via `formatXlm`, so tiny rewards never display as `0.00`.
- **Pool share** is `contribution / poolTotalLiquidity` in integer math,
floored to 0.01%. A non-zero share under 0.01% reads `<0.01%`. If the pool
total is missing, the contract's basis-point `share` is used instead.
- **Earnings total** de-duplicates by `lpId` and is withheld ("Unavailable")
when any position's rewards can't be read, rather than showing a partial sum.
- **Add liquidity** validates format, > 0, ≤ 7 decimals, and ≤ spendable XLM
(balance minus account reserve and selling liabilities). The mutation reads
the balance again right before signing, so a balance that dropped after the
modal opened is caught.
- **Reward rate preview** shows the API's APR estimate and the pool share the
deposit would receive using the contract formula
`amount / (total_liquidity + amount)`. No reward-rate formula is invented
client-side; if the API has no APR the preview says "Unavailable".
- **Claim** simulates `claim_lp_rewards` first. If the contract would pay out
zero, the wallet is never prompted.
- **Remove** is disabled while `unlocksAt` is in the future, with a live
countdown (`2d 14h 32m`). An unparseable `unlocksAt` blocks removal. Before
signing, the mutation refetches positions and re-checks the lock, and the
contract simulation has the final say.
- **Confirmation.** Success toasts (with tx hash and Stellar Expert link)
appear only after `getTransaction` reports `SUCCESS`. A transaction that
lands but fails on-chain surfaces as an error.
- **Refresh.** After a confirmed transaction the hooks invalidate
`queryKeys.lp.positions(wallet)`, `queryKeys.lp.pool(keyId)`, and
`queryKeys.wallet.xlmBalance(wallet)`. Positions also refetch every 30s and
when a lock countdown reaches zero.

## Known limitations

- **Lock period:** the LP reward contract in PR #1004 has no lock. `unlocksAt`
is honoured if the API provides it. Without it, removal is allowed and the
contract decides.
- **Asset:** the contract does not name the deposited token. The UI assumes
XLM (stroops, 7 decimals) like every other amount in the app.
- **Entry point:** "Add liquidity" is offered per existing pool. Opening a
first position in a new pool needs an entry point on the creator page,
which is outside this tab's scope.
- **Base reserve** is fixed at 0.5 XLM (`BASE_RESERVE_STROOPS`), the current
value on mainnet and testnet.

## Configuration

Requires `VITE_STELLAR_RPC_URL` and `VITE_CREATOR_KEYS_CONTRACT_ID` (see
`.env.example`). Without them, reads from the API still work but actions show
a clear "not configured" error.
274 changes: 274 additions & 0 deletions src/components/common/AddLiquidityDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import React, { useState } from 'react';
import { LoaderCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useLpPool, useSpendableXlmBalance } from '@/hooks/useLpPositions';
import type { LpPosition } from '@/services/lpPositions.service';
import {
formatLpAmount,
formatPoolShare,
formatProjectedPoolShare,
stroopsToXlmInput,
validateLpAmountInput,
} from '@/utils/lpPositions.utils';
import { bpsToPercent } from '@/utils/numberFormat.utils';

export interface AddLiquidityDialogProps {
/** Pool context; the dialog is open while this is non-null. */
position: LpPosition | null;
/** Connected Stellar address whose balance funds the deposit. */
wallet: string;
onClose: () => void;
/** Submits the raw input; the mutation re-validates against a fresh balance. */
onSubmit: (amountInput: string) => void;
isSubmitting: boolean;
/** Message from the last failed submission, shown inline. */
submitError?: string | null;
}

/**
* Add-liquidity modal (#1030).
*
* Validates the amount against the wallet's spendable XLM (read from chain)
* before enabling submit, previews the pool's current APR estimate from the
* LP API, and shows the pool share this deposit would receive using the
* contract's own `add_liquidity` share formula.
*/
const AddLiquidityDialog: React.FC<AddLiquidityDialogProps> = ({
position,
wallet,
onClose,
onSubmit,
isSubmitting,
submitError,
}) => {
const open = position !== null;
const [amountInput, setAmountInput] = useState('');
const [touched, setTouched] = useState(false);
const balanceQuery = useSpendableXlmBalance(wallet, open);
const poolQuery = useLpPool(position?.keyId, open);

const availableStroops = balanceQuery.data ?? null;
const validation = validateLpAmountInput(amountInput, { availableStroops });
const showError = touched && !validation.ok && !balanceQuery.isLoading;
const errorId = 'add-liquidity-amount-error';

const pool = poolQuery.data;
const poolTotal =
pool?.totalLiquidityStroops ??
position?.poolTotalLiquidityStroops ??
null;

const handleOpenChange = (next: boolean) => {
if (next || isSubmitting) return;
setAmountInput('');
setTouched(false);
onClose();
};

const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
setTouched(true);
if (!validation.ok || isSubmitting) return;
onSubmit(amountInput.trim());
};

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add liquidity</DialogTitle>
<DialogDescription className="break-words">
Deposit XLM into the {position?.keyName} pool to earn a share
of its trading fees.
</DialogDescription>
</DialogHeader>

{position && (
<form
id="add-liquidity-form"
onSubmit={handleSubmit}
noValidate
className="space-y-4"
>
<div className="rounded-xl border border-white/10 bg-slate-950/30 p-3 text-xs text-white/60">
<div className="flex justify-between gap-3">
<span>Current contribution</span>
<span className="break-all text-right font-mono text-white/85">
{formatLpAmount(position.contributionStroops)}
</span>
</div>
<div className="mt-1 flex justify-between gap-3">
<span>Current pool share</span>
<span className="font-mono text-white/85">
{formatPoolShare(position)}
</span>
</div>
</div>

<div>
<div className="mb-1.5 flex items-end justify-between gap-3">
<label
htmlFor="add-liquidity-amount"
className="text-sm font-medium text-white"
>
Amount (XLM)
</label>
<span
data-testid="add-liquidity-available"
className="text-right text-xs text-white/50"
>
Available:{' '}
{balanceQuery.isLoading
? 'Loading…'
: balanceQuery.isError
? 'Unavailable'
: formatLpAmount(availableStroops)}
</span>
</div>
<div className="flex gap-2">
<input
id="add-liquidity-amount"
name="amount"
type="text"
inputMode="decimal"
autoComplete="off"
placeholder="0.0"
value={amountInput}
onChange={event => {
setAmountInput(event.target.value);
setTouched(true);
}}
disabled={isSubmitting}
aria-invalid={showError || undefined}
aria-describedby={showError ? errorId : undefined}
className="min-w-0 flex-1 rounded-lg border border-white/15 bg-white/5 px-3 py-2 font-mono text-sm text-white outline-none placeholder:text-white/30 focus:border-amber-300/60"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={
isSubmitting ||
availableStroops == null ||
availableStroops <= 0n
}
onClick={() => {
if (availableStroops == null) return;
setAmountInput(
stroopsToXlmInput(availableStroops)
);
setTouched(true);
}}
>
Max
</Button>
</div>
{showError && !validation.ok && (
<p
id={errorId}
role="alert"
data-testid="add-liquidity-error"
className="mt-1.5 text-xs text-rose-300"
>
{validation.message}
</p>
)}
</div>

<div
data-testid="add-liquidity-reward-preview"
className="rounded-xl border border-amber-300/20 bg-amber-300/5 p-3 text-xs"
>
<p className="font-semibold uppercase tracking-[0.16em] text-amber-300">
Reward rate preview
</p>
{poolQuery.isLoading ? (
<p className="mt-2 text-white/60">Loading pool data…</p>
) : (
<dl className="mt-2 space-y-1 text-white/60">
<div className="flex justify-between gap-3">
<dt>Current APR (estimate)</dt>
<dd
data-testid="add-liquidity-apr"
className="font-mono text-white/85"
>
{pool?.aprBps != null
? bpsToPercent(pool.aprBps)
: 'Unavailable'}
</dd>
</div>
<div className="flex justify-between gap-3">
<dt>Share of pool for this deposit</dt>
<dd
data-testid="add-liquidity-projected-share"
className="font-mono text-white/85"
>
{validation.ok
? formatProjectedPoolShare(
validation.stroops,
poolTotal
)
: '—'}
</dd>
</div>
</dl>
)}
<p className="mt-2 leading-5 text-white/45">
Rewards come from trading fees and change with volume
and total pool size. The APR is an estimate, not a
guarantee.
</p>
</div>

{submitError && (
<p
role="alert"
data-testid="add-liquidity-submit-error"
className="text-sm text-rose-300"
>
{submitError}
</p>
)}
</form>
)}

<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
form="add-liquidity-form"
disabled={isSubmitting || !validation.ok}
data-testid="add-liquidity-submit"
>
{isSubmitting && (
<LoaderCircle
className="size-4 animate-spin"
aria-hidden="true"
/>
)}
{isSubmitting
? 'Confirm in wallet…'
: 'Sign and add liquidity'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

export default AddLiquidityDialog;
Loading
Loading