Skip to content

Commit 566e9ad

Browse files
feat: add LP position management to the portfolio page (#1030)
Adds a Liquidity tab to /profile (read-only on /profile/:wallet) showing active LP positions, total LP earnings, and add / claim / remove controls. - Reads positions and pool APR from the LP API specified in accesslayer-server #980 (GET /lp/positions, GET /lp/pool/:keyId). - Submits add_liquidity / claim_lp_rewards / remove_liquidity on the Creator Keys contract (accesslayer-contracts #1002, PR #1004) through the existing Soroban Client + Signer pattern used by governance votes. - Validates add-liquidity amounts against spendable XLM read from the account ledger entry (net of reserve and selling liabilities), and re-validates against a fresh balance right before signing. - Pool share uses exact integer math, floored to 0.01%. All amounts are bigint stroops end to end. - Claim is simulated first and never prompts the wallet for a zero payout. Removal is blocked with a live countdown while locked, and the lock is re-checked from fresh data before signing. - Success is only reported once getTransaction returns SUCCESS; an on-chain FAILED is surfaced as an error. - Earnings total is de-duplicated by lpId and withheld when any position's rewards are unavailable. Documented in docs/LpPositionManagement.md, including the expected API shape and the known limitations (no lock in the current contract, XLM assumed as the pool asset). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 78ee248 commit 566e9ad

21 files changed

Lines changed: 4551 additions & 0 deletions

‎docs/LpPositionManagement.md‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# LP Position Management (Portfolio → Liquidity)
2+
3+
The **Liquidity** tab on `/profile` (and `/profile/:wallet`, read-only) lets a
4+
liquidity provider see their active LP positions and manage them (#1030).
5+
6+
## What it shows
7+
8+
- **Total LP earnings** card: unclaimed rewards + rewards already claimed from
9+
the active positions, plus a position count.
10+
- One row per active position: pool / key name, contributed amount, pool
11+
share %, accrued (unclaimed) rewards, and lock status.
12+
- Per-position actions, rendered only when the connected Stellar signer owns
13+
the positions: **Add liquidity**, **Claim rewards**, **Remove liquidity**.
14+
15+
## Where the data comes from
16+
17+
| Concern | Source | Code |
18+
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
19+
| Positions, rewards, lock | `GET /lp/positions?wallet=` (accesslayer-server #980) | `services/lpPositions.service.ts` |
20+
| Pool size, APR estimate | `GET /lp/pool/:keyId` (accesslayer-server #980) | `services/lpPositions.service.ts` |
21+
| Spendable XLM | Account ledger entry over Soroban RPC (`VITE_STELLAR_RPC_URL`) | `services/stellarAccount.service.ts` |
22+
| Add / claim / remove | Creator Keys contract `add_liquidity` / `claim_lp_rewards` / `remove_liquidity` (accesslayer-contracts #1002, PR #1004) | `services/lpContract.service.ts` |
23+
24+
Hooks live in `hooks/useLpPositions.ts`; pure math lives in
25+
`utils/lpPositions.utils.ts`.
26+
27+
### Expected API shapes
28+
29+
The server endpoints are specified in accesslayer-server #980 but were not
30+
merged when this UI was built. The client expects the following shape and
31+
validates every field. Amounts are **stroops as integer strings** (i128 in
32+
the contract); numbers are accepted only when they are safe integers.
33+
34+
```jsonc
35+
// GET /lp/positions?wallet=G... → APIResponse<{ positions: [...] }> (a bare array also works)
36+
{
37+
"lpId": "7", // contract lp_id (u64)
38+
"keyId": "C...", // contract key_id (Address)
39+
"keyName": "Alpha Key", // optional, falls back to a shortened keyId
40+
"creatorId": "alpha", // optional, enables the /creator/:id link
41+
"contribution": "250000000",
42+
"share": 2500, // optional, contract basis points
43+
"poolTotalLiquidity": "1000000000", // optional, preferred for an exact share
44+
"pendingRewards": "12345", // missing/invalid → shown as "Unavailable"
45+
"claimedRewards": "100", // optional, lifetime claimed from this position
46+
"unlocksAt": "2030-01-01T00:00:00Z", // optional; ISO, epoch s, or epoch ms
47+
}
48+
49+
// GET /lp/pool/:keyId → APIResponse<{ keyId, totalLiquidity, aprBps }>
50+
```
51+
52+
Records missing `lpId`, `keyId`, or a valid `contribution` are dropped, as are
53+
closed positions (`contribution = 0`, which is how the contract reports them).
54+
55+
## Rules the UI follows
56+
57+
- **Precision.** All amounts are `bigint` stroops end to end and are rendered
58+
with 7 decimals via `formatXlm`, so tiny rewards never display as `0.00`.
59+
- **Pool share** is `contribution / poolTotalLiquidity` in integer math,
60+
floored to 0.01%. A non-zero share under 0.01% reads `<0.01%`. If the pool
61+
total is missing, the contract's basis-point `share` is used instead.
62+
- **Earnings total** de-duplicates by `lpId` and is withheld ("Unavailable")
63+
when any position's rewards can't be read, rather than showing a partial sum.
64+
- **Add liquidity** validates format, > 0, ≤ 7 decimals, and ≤ spendable XLM
65+
(balance minus account reserve and selling liabilities). The mutation reads
66+
the balance again right before signing, so a balance that dropped after the
67+
modal opened is caught.
68+
- **Reward rate preview** shows the API's APR estimate and the pool share the
69+
deposit would receive using the contract formula
70+
`amount / (total_liquidity + amount)`. No reward-rate formula is invented
71+
client-side; if the API has no APR the preview says "Unavailable".
72+
- **Claim** simulates `claim_lp_rewards` first. If the contract would pay out
73+
zero, the wallet is never prompted.
74+
- **Remove** is disabled while `unlocksAt` is in the future, with a live
75+
countdown (`2d 14h 32m`). An unparseable `unlocksAt` blocks removal. Before
76+
signing, the mutation refetches positions and re-checks the lock, and the
77+
contract simulation has the final say.
78+
- **Confirmation.** Success toasts (with tx hash and Stellar Expert link)
79+
appear only after `getTransaction` reports `SUCCESS`. A transaction that
80+
lands but fails on-chain surfaces as an error.
81+
- **Refresh.** After a confirmed transaction the hooks invalidate
82+
`queryKeys.lp.positions(wallet)`, `queryKeys.lp.pool(keyId)`, and
83+
`queryKeys.wallet.xlmBalance(wallet)`. Positions also refetch every 30s and
84+
when a lock countdown reaches zero.
85+
86+
## Known limitations
87+
88+
- **Lock period:** the LP reward contract in PR #1004 has no lock. `unlocksAt`
89+
is honoured if the API provides it. Without it, removal is allowed and the
90+
contract decides.
91+
- **Asset:** the contract does not name the deposited token. The UI assumes
92+
XLM (stroops, 7 decimals) like every other amount in the app.
93+
- **Entry point:** "Add liquidity" is offered per existing pool. Opening a
94+
first position in a new pool needs an entry point on the creator page,
95+
which is outside this tab's scope.
96+
- **Base reserve** is fixed at 0.5 XLM (`BASE_RESERVE_STROOPS`), the current
97+
value on mainnet and testnet.
98+
99+
## Configuration
100+
101+
Requires `VITE_STELLAR_RPC_URL` and `VITE_CREATOR_KEYS_CONTRACT_ID` (see
102+
`.env.example`). Without them, reads from the API still work but actions show
103+
a clear "not configured" error.
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import React, { useState } from 'react';
2+
import { LoaderCircle } from 'lucide-react';
3+
import { Button } from '@/components/ui/button';
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from '@/components/ui/dialog';
12+
import { useLpPool, useSpendableXlmBalance } from '@/hooks/useLpPositions';
13+
import type { LpPosition } from '@/services/lpPositions.service';
14+
import {
15+
formatLpAmount,
16+
formatPoolShare,
17+
formatProjectedPoolShare,
18+
stroopsToXlmInput,
19+
validateLpAmountInput,
20+
} from '@/utils/lpPositions.utils';
21+
import { bpsToPercent } from '@/utils/numberFormat.utils';
22+
23+
export interface AddLiquidityDialogProps {
24+
/** Pool context; the dialog is open while this is non-null. */
25+
position: LpPosition | null;
26+
/** Connected Stellar address whose balance funds the deposit. */
27+
wallet: string;
28+
onClose: () => void;
29+
/** Submits the raw input; the mutation re-validates against a fresh balance. */
30+
onSubmit: (amountInput: string) => void;
31+
isSubmitting: boolean;
32+
/** Message from the last failed submission, shown inline. */
33+
submitError?: string | null;
34+
}
35+
36+
/**
37+
* Add-liquidity modal (#1030).
38+
*
39+
* Validates the amount against the wallet's spendable XLM (read from chain)
40+
* before enabling submit, previews the pool's current APR estimate from the
41+
* LP API, and shows the pool share this deposit would receive using the
42+
* contract's own `add_liquidity` share formula.
43+
*/
44+
const AddLiquidityDialog: React.FC<AddLiquidityDialogProps> = ({
45+
position,
46+
wallet,
47+
onClose,
48+
onSubmit,
49+
isSubmitting,
50+
submitError,
51+
}) => {
52+
const open = position !== null;
53+
const [amountInput, setAmountInput] = useState('');
54+
const [touched, setTouched] = useState(false);
55+
const balanceQuery = useSpendableXlmBalance(wallet, open);
56+
const poolQuery = useLpPool(position?.keyId, open);
57+
58+
const availableStroops = balanceQuery.data ?? null;
59+
const validation = validateLpAmountInput(amountInput, { availableStroops });
60+
const showError = touched && !validation.ok && !balanceQuery.isLoading;
61+
const errorId = 'add-liquidity-amount-error';
62+
63+
const pool = poolQuery.data;
64+
const poolTotal =
65+
pool?.totalLiquidityStroops ??
66+
position?.poolTotalLiquidityStroops ??
67+
null;
68+
69+
const handleOpenChange = (next: boolean) => {
70+
if (next || isSubmitting) return;
71+
setAmountInput('');
72+
setTouched(false);
73+
onClose();
74+
};
75+
76+
const handleSubmit = (event: React.FormEvent) => {
77+
event.preventDefault();
78+
setTouched(true);
79+
if (!validation.ok || isSubmitting) return;
80+
onSubmit(amountInput.trim());
81+
};
82+
83+
return (
84+
<Dialog open={open} onOpenChange={handleOpenChange}>
85+
<DialogContent className="max-h-[90vh] overflow-y-auto">
86+
<DialogHeader>
87+
<DialogTitle>Add liquidity</DialogTitle>
88+
<DialogDescription className="break-words">
89+
Deposit XLM into the {position?.keyName} pool to earn a share
90+
of its trading fees.
91+
</DialogDescription>
92+
</DialogHeader>
93+
94+
{position && (
95+
<form
96+
id="add-liquidity-form"
97+
onSubmit={handleSubmit}
98+
noValidate
99+
className="space-y-4"
100+
>
101+
<div className="rounded-xl border border-white/10 bg-slate-950/30 p-3 text-xs text-white/60">
102+
<div className="flex justify-between gap-3">
103+
<span>Current contribution</span>
104+
<span className="break-all text-right font-mono text-white/85">
105+
{formatLpAmount(position.contributionStroops)}
106+
</span>
107+
</div>
108+
<div className="mt-1 flex justify-between gap-3">
109+
<span>Current pool share</span>
110+
<span className="font-mono text-white/85">
111+
{formatPoolShare(position)}
112+
</span>
113+
</div>
114+
</div>
115+
116+
<div>
117+
<div className="mb-1.5 flex items-end justify-between gap-3">
118+
<label
119+
htmlFor="add-liquidity-amount"
120+
className="text-sm font-medium text-white"
121+
>
122+
Amount (XLM)
123+
</label>
124+
<span
125+
data-testid="add-liquidity-available"
126+
className="text-right text-xs text-white/50"
127+
>
128+
Available:{' '}
129+
{balanceQuery.isLoading
130+
? 'Loading…'
131+
: balanceQuery.isError
132+
? 'Unavailable'
133+
: formatLpAmount(availableStroops)}
134+
</span>
135+
</div>
136+
<div className="flex gap-2">
137+
<input
138+
id="add-liquidity-amount"
139+
name="amount"
140+
type="text"
141+
inputMode="decimal"
142+
autoComplete="off"
143+
placeholder="0.0"
144+
value={amountInput}
145+
onChange={event => {
146+
setAmountInput(event.target.value);
147+
setTouched(true);
148+
}}
149+
disabled={isSubmitting}
150+
aria-invalid={showError || undefined}
151+
aria-describedby={showError ? errorId : undefined}
152+
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"
153+
/>
154+
<Button
155+
type="button"
156+
variant="outline"
157+
size="sm"
158+
disabled={
159+
isSubmitting ||
160+
availableStroops == null ||
161+
availableStroops <= 0n
162+
}
163+
onClick={() => {
164+
if (availableStroops == null) return;
165+
setAmountInput(
166+
stroopsToXlmInput(availableStroops)
167+
);
168+
setTouched(true);
169+
}}
170+
>
171+
Max
172+
</Button>
173+
</div>
174+
{showError && !validation.ok && (
175+
<p
176+
id={errorId}
177+
role="alert"
178+
data-testid="add-liquidity-error"
179+
className="mt-1.5 text-xs text-rose-300"
180+
>
181+
{validation.message}
182+
</p>
183+
)}
184+
</div>
185+
186+
<div
187+
data-testid="add-liquidity-reward-preview"
188+
className="rounded-xl border border-amber-300/20 bg-amber-300/5 p-3 text-xs"
189+
>
190+
<p className="font-semibold uppercase tracking-[0.16em] text-amber-300">
191+
Reward rate preview
192+
</p>
193+
{poolQuery.isLoading ? (
194+
<p className="mt-2 text-white/60">Loading pool data…</p>
195+
) : (
196+
<dl className="mt-2 space-y-1 text-white/60">
197+
<div className="flex justify-between gap-3">
198+
<dt>Current APR (estimate)</dt>
199+
<dd
200+
data-testid="add-liquidity-apr"
201+
className="font-mono text-white/85"
202+
>
203+
{pool?.aprBps != null
204+
? bpsToPercent(pool.aprBps)
205+
: 'Unavailable'}
206+
</dd>
207+
</div>
208+
<div className="flex justify-between gap-3">
209+
<dt>Share of pool for this deposit</dt>
210+
<dd
211+
data-testid="add-liquidity-projected-share"
212+
className="font-mono text-white/85"
213+
>
214+
{validation.ok
215+
? formatProjectedPoolShare(
216+
validation.stroops,
217+
poolTotal
218+
)
219+
: '—'}
220+
</dd>
221+
</div>
222+
</dl>
223+
)}
224+
<p className="mt-2 leading-5 text-white/45">
225+
Rewards come from trading fees and change with volume
226+
and total pool size. The APR is an estimate, not a
227+
guarantee.
228+
</p>
229+
</div>
230+
231+
{submitError && (
232+
<p
233+
role="alert"
234+
data-testid="add-liquidity-submit-error"
235+
className="text-sm text-rose-300"
236+
>
237+
{submitError}
238+
</p>
239+
)}
240+
</form>
241+
)}
242+
243+
<DialogFooter>
244+
<Button
245+
type="button"
246+
variant="outline"
247+
onClick={() => handleOpenChange(false)}
248+
disabled={isSubmitting}
249+
>
250+
Cancel
251+
</Button>
252+
<Button
253+
type="submit"
254+
form="add-liquidity-form"
255+
disabled={isSubmitting || !validation.ok}
256+
data-testid="add-liquidity-submit"
257+
>
258+
{isSubmitting && (
259+
<LoaderCircle
260+
className="size-4 animate-spin"
261+
aria-hidden="true"
262+
/>
263+
)}
264+
{isSubmitting
265+
? 'Confirm in wallet…'
266+
: 'Sign and add liquidity'}
267+
</Button>
268+
</DialogFooter>
269+
</DialogContent>
270+
</Dialog>
271+
);
272+
};
273+
274+
export default AddLiquidityDialog;

0 commit comments

Comments
 (0)