Skip to content

Commit 8ab43e0

Browse files
authored
Merge pull request StellarFlow-Network#784 from Noble-Devbase/feat/buil-multi-assest-single
muliti assest
2 parents 8cdb820 + 56664cd commit 8ab43e0

2 files changed

Lines changed: 228 additions & 37 deletions

File tree

src/components/pools/AddLiquidityModal.tsx

Lines changed: 127 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
WalletProvider,
99
useWallet,
1010
} from "@/app/components/providers/WalletProvider";
11+
import { submitSingleSidedLiquidity } from "@/lib/liquidityOps";
1112

1213
// ---------------------------------------------------------------------------
1314
// Types
@@ -38,6 +39,8 @@ export interface AddLiquidityModalProps {
3839
assetB: PoolAsset;
3940
/** On-chain reserves used to compute the counter-asset amount and LP estimate */
4041
reserves: PoolReserves;
42+
/** Soroban pool contract that owns the atomic swap-and-mint entry point. */
43+
poolContractId?: string;
4144
/** Human-readable pool identifier shown in the modal header */
4245
poolLabel?: string;
4346
onDepositSuccess?: (txHash: string) => void;
@@ -127,6 +130,47 @@ function parseAmount(raw: string): number {
127130
return isFinite(parsed) && parsed >= 0 ? parsed : NaN;
128131
}
129132

133+
export interface SingleSidedDepositQuote {
134+
inputAmount: number;
135+
swapInputAmount: number;
136+
swapOutputAmount: number;
137+
finalAmountA: number;
138+
finalAmountB: number;
139+
}
140+
141+
/** Solves the reserve-aware 50/50 split before the transaction is signed. */
142+
export function calculateSingleSidedDepositQuote(
143+
inputAmount: number,
144+
inputAsset: "A" | "B",
145+
reserves: PoolReserves,
146+
feeBps = 30,
147+
): SingleSidedDepositQuote {
148+
const reserveIn = inputAsset === "A" ? reserves.reserveA : reserves.reserveB;
149+
const reserveOut = inputAsset === "A" ? reserves.reserveB : reserves.reserveA;
150+
if (!isFinite(inputAmount) || inputAmount <= 0 || reserveIn <= 0 || reserveOut <= 0) {
151+
return { inputAmount: 0, swapInputAmount: 0, swapOutputAmount: 0, finalAmountA: 0, finalAmountB: 0 };
152+
}
153+
154+
const feeFactor = 1 - feeBps / 10_000;
155+
const targetRatio = reserveOut / reserveIn;
156+
let low = 0;
157+
let high = inputAmount;
158+
for (let iteration = 0; iteration < 64; iteration += 1) {
159+
const swapInput = (low + high) / 2;
160+
const effectiveInput = swapInput * feeFactor;
161+
const swapOutput = (effectiveInput * reserveOut) / (reserveIn + effectiveInput);
162+
if (swapOutput > (inputAmount - swapInput) * targetRatio) high = swapInput;
163+
else low = swapInput;
164+
}
165+
166+
const swapInputAmount = (low + high) / 2;
167+
const effectiveInput = swapInputAmount * feeFactor;
168+
const swapOutputAmount = (effectiveInput * reserveOut) / (reserveIn + effectiveInput);
169+
return inputAsset === "A"
170+
? { inputAmount, swapInputAmount, swapOutputAmount, finalAmountA: inputAmount - swapInputAmount, finalAmountB: swapOutputAmount }
171+
: { inputAmount, swapInputAmount, swapOutputAmount, finalAmountA: swapOutputAmount, finalAmountB: inputAmount - swapInputAmount };
172+
}
173+
130174
// ---------------------------------------------------------------------------
131175
// Inner modal implementation (requires WalletProvider in the tree)
132176
// ---------------------------------------------------------------------------
@@ -138,12 +182,15 @@ function AddLiquidityModalContent({
138182
assetB,
139183
reserves,
140184
poolLabel,
185+
poolContractId,
141186
onDepositSuccess,
142187
onDepositError,
143188
}: AddLiquidityModalProps) {
144189
// ── Input state ────────────────────────────────────────────────────────────
145190
const [rawAmountA, setRawAmountA] = useState("");
146191
const [touchedA, setTouchedA] = useState(false);
192+
const [inputAsset, setInputAsset] = useState<"A" | "B">("A");
193+
const [isConfirmationOpen, setIsConfirmationOpen] = useState(false);
147194

148195
// ── Submission state ───────────────────────────────────────────────────────
149196
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -160,12 +207,13 @@ function AddLiquidityModalContent({
160207
const { wallet } = useWallet();
161208

162209
// ── Derived calculations (memoised) ────────────────────────────────────────
163-
const amountA = useMemo(() => parseAmount(rawAmountA), [rawAmountA]);
164-
165-
const amountB = useMemo(
166-
() => calcCounterAmount(amountA, reserves.reserveA, reserves.reserveB),
167-
[amountA, reserves.reserveA, reserves.reserveB],
210+
const inputAmount = useMemo(() => parseAmount(rawAmountA), [rawAmountA]);
211+
const quote = useMemo(
212+
() => calculateSingleSidedDepositQuote(inputAmount, inputAsset, reserves),
213+
[inputAmount, inputAsset, reserves],
168214
);
215+
const amountA = quote.finalAmountA;
216+
const amountB = quote.finalAmountB;
169217

170218
const lpMintEstimate = useMemo(
171219
() => calcLpMintEstimate(amountA, amountB, reserves),
@@ -178,13 +226,12 @@ function AddLiquidityModalContent({
178226
);
179227

180228
const isPoolEmpty = reserves.reserveA === 0 && reserves.reserveB === 0;
181-
const hasValidAmounts =
182-
isFinite(amountA) && amountA > 0 && isFinite(amountB) && amountB > 0;
229+
const hasValidAmounts = !isPoolEmpty && amountA > 0 && amountB > 0;
183230

184231
// ── Approval helpers ───────────────────────────────────────────────────────
185232

186-
const bothApproved =
187-
approval.assetA === "approved" && approval.assetB === "approved";
233+
const inputApprovalKey = inputAsset === "A" ? "assetA" : "assetB";
234+
const inputApproved = approval[inputApprovalKey] === "approved";
188235

189236
/**
190237
* Simulate on-chain allowance check for a single asset.
@@ -255,6 +302,8 @@ function AddLiquidityModalContent({
255302
if (!isOpen) {
256303
setRawAmountA("");
257304
setTouchedA(false);
305+
setInputAsset("A");
306+
setIsConfirmationOpen(false);
258307
setSubmitError(null);
259308
setTxHash(null);
260309
setIsSubmitting(false);
@@ -278,23 +327,31 @@ function AddLiquidityModalContent({
278327
return;
279328
}
280329

281-
if (!bothApproved) {
282-
setSubmitError(
283-
"Both assets must be approved before depositing. Use the Approve buttons above.",
284-
);
330+
if (!inputApproved) {
331+
setSubmitError(`Approve ${inputAsset === "A" ? assetA.symbol : assetB.symbol} before depositing.`);
285332
return;
286333
}
287334

335+
setIsConfirmationOpen(true);
336+
};
337+
338+
const handleConfirm = async () => {
288339
setIsSubmitting(true);
289340

290341
try {
291-
const { submitTransaction } = await import("@/lib/transactionOps");
292-
const hash = await submitTransaction({
293-
[assetA.contractId]: amountA,
294-
[assetB.contractId]: amountB,
342+
if (!poolContractId) throw new Error("This pool is missing its contract address.");
343+
const toAtomic = (amount: number) => BigInt(Math.floor(amount * 1_000_000));
344+
const result = await submitSingleSidedLiquidity({
345+
poolContractId,
346+
tokenInId: inputAsset === "A" ? assetA.contractId : assetB.contractId,
347+
amountIn: toAtomic(inputAmount),
348+
minAmountA: toAtomic(amountA * 0.995),
349+
minAmountB: toAtomic(amountB * 0.995),
350+
minLpAmount: toAtomic(lpMintEstimate * 0.995),
295351
});
296-
setTxHash(hash);
297-
onDepositSuccess?.(hash);
352+
setTxHash(result.txHash);
353+
setIsConfirmationOpen(false);
354+
onDepositSuccess?.(result.txHash);
298355
} catch (err) {
299356
const error =
300357
err instanceof Error ? err : new Error("Deposit transaction failed.");
@@ -307,13 +364,13 @@ function AddLiquidityModalContent({
307364

308365
// ── Derived UI states ──────────────────────────────────────────────────────
309366
const amountAError =
310-
touchedA && rawAmountA !== "" && !isFinite(amountA)
367+
touchedA && rawAmountA !== "" && (!isFinite(inputAmount) || inputAmount <= 0)
311368
? "Enter a valid positive number."
312369
: null;
313370

314371
const canSubmit =
315372
hasValidAmounts &&
316-
bothApproved &&
373+
inputApproved &&
317374
!isSubmitting &&
318375
txHash === null &&
319376
wallet?.connected === true;
@@ -360,14 +417,32 @@ function AddLiquidityModalContent({
360417
</div>
361418
</div>
362419

363-
{/* ── Asset A input ──────────────────────────────────────────────── */}
420+
{/* ── Single-token input ─────────────────────────────────────────── */}
364421
<div className="space-y-1.5">
422+
<div className="flex items-center justify-between gap-3">
365423
<label
366424
htmlFor="add-liq-amount-a"
367425
className="text-xs uppercase font-bold text-gray-500"
368426
>
369-
{assetA.symbol} Amount
427+
Deposit Token
370428
</label>
429+
<div className="flex rounded-lg border border-gray-700 bg-[#0d1117] p-0.5" role="group" aria-label="Deposit token">
430+
{(["A", "B"] as const).map((key) => {
431+
const asset = key === "A" ? assetA : assetB;
432+
return (
433+
<button
434+
key={key}
435+
type="button"
436+
onClick={() => { setInputAsset(key); setApproval({ assetA: "idle", assetB: "idle" }); setSubmitError(null); }}
437+
className={`px-2.5 py-1 text-xs font-semibold rounded-md ${inputAsset === key ? "bg-blue-600 text-white" : "text-gray-400 hover:text-gray-200"}`}
438+
disabled={isSubmitting || txHash !== null}
439+
>
440+
{asset.symbol}
441+
</button>
442+
);
443+
})}
444+
</div>
445+
</div>
371446
<div className="relative">
372447
<input
373448
id="add-liq-amount-a"
@@ -403,13 +478,13 @@ function AddLiquidityModalContent({
403478
)}
404479
</div>
405480

406-
{/* ── Asset B (auto-computed) ────────────────────────────────────── */}
481+
{/* ── Calculated balanced contribution ───────────────────────────── */}
407482
<div className="space-y-1.5">
408483
<label
409484
htmlFor="add-liq-amount-b"
410485
className="text-xs uppercase font-bold text-gray-500"
411486
>
412-
{assetB.symbol} Amount{" "}
487+
Balanced Pool Contribution{" "}
413488
<span className="normal-case font-normal text-gray-600">
414489
(calculated)
415490
</span>
@@ -421,22 +496,22 @@ function AddLiquidityModalContent({
421496
readOnly
422497
value={
423498
hasValidAmounts
424-
? amountB.toFixed(6)
425-
: isPoolEmpty && isFinite(amountA) && amountA > 0
499+
? `${amountA.toFixed(6)} ${assetA.symbol} + ${amountB.toFixed(6)} ${assetB.symbol}`
500+
: isPoolEmpty && isFinite(inputAmount) && inputAmount > 0
426501
? "—"
427502
: ""
428503
}
429504
placeholder="0.000000"
430505
className="w-full rounded-lg border border-gray-700 bg-[#0d1117]/60 px-3 py-2.5 font-mono text-sm text-gray-400 placeholder:text-gray-600 focus:outline-none cursor-default pr-16"
431-
aria-label={`Required ${assetB.symbol} amount (auto-calculated)`}
506+
aria-label={`Calculated balanced ${assetA.symbol} and ${assetB.symbol} contribution`}
432507
/>
433508
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs font-semibold text-gray-400 pointer-events-none">
434-
{assetB.symbol}
509+
{assetA.symbol} / {assetB.symbol}
435510
</span>
436511
</div>
437512
{!isPoolEmpty && (
438513
<p className="text-xs text-gray-600">
439-
Automatically matched to the pool reserve ratio to prevent slippage.
514+
The required swap is calculated from the pool ratio before signing.
440515
</p>
441516
)}
442517
{isPoolEmpty && (
@@ -495,14 +570,14 @@ function AddLiquidityModalContent({
495570
</div>
496571
)}
497572

498-
{/* ── Dual-asset approval state machine ─────────────────────────── */}
573+
{/* ── Input-token approval state machine ─────────────────────────── */}
499574
<div className="space-y-2">
500575
<p className="text-xs uppercase font-bold text-gray-500">
501-
Asset Approvals
576+
Input Token Approval
502577
</p>
503-
<div className="grid grid-cols-2 gap-3">
504-
{(["assetA", "assetB"] as const).map((key) => {
505-
const asset = key === "assetA" ? assetA : assetB;
578+
<div className="grid grid-cols-1 gap-3">
579+
{([inputApprovalKey] as const).map((key) => {
580+
const asset = inputAsset === "A" ? assetA : assetB;
506581
const status = approval[key];
507582
const isApproved = status === "approved";
508583
const isLoading =
@@ -594,6 +669,20 @@ function AddLiquidityModalContent({
594669
</div>
595670
)}
596671

672+
{isConfirmationOpen && hasValidAmounts && (
673+
<div className="rounded-lg border border-amber-500/30 bg-amber-950/10 p-4 space-y-3" role="region" aria-label="Deposit confirmation">
674+
<div>
675+
<p className="text-xs uppercase font-bold tracking-wider text-amber-400">Review atomic deposit</p>
676+
<p className="mt-1 text-xs text-gray-400">One transaction performs the internal swap and LP mint.</p>
677+
</div>
678+
<div className="space-y-2 text-sm">
679+
<div className="flex justify-between gap-4"><span className="text-gray-400">Internal swap</span><span className="font-mono text-gray-200">{quote.swapInputAmount.toFixed(6)} {inputAsset === "A" ? assetA.symbol : assetB.symbol}</span></div>
680+
<div className="flex justify-between gap-4"><span className="text-gray-400">Swap receives</span><span className="font-mono text-gray-200">{quote.swapOutputAmount.toFixed(6)} {inputAsset === "A" ? assetB.symbol : assetA.symbol}</span></div>
681+
<div className="flex justify-between gap-4 border-t border-amber-500/10 pt-2"><span className="text-gray-400">LP token mint</span><span className="font-mono font-semibold text-emerald-400">{lpMintEstimate.toFixed(6)} LP</span></div>
682+
</div>
683+
</div>
684+
)}
685+
597686
{/* ── Success banner ─────────────────────────────────────────────── */}
598687
{txHash && (
599688
<div className="rounded-lg border border-emerald-500/40 bg-emerald-950/20 px-3 py-2 text-sm text-emerald-300">
@@ -613,11 +702,12 @@ function AddLiquidityModalContent({
613702
</button>
614703
{!txHash && (
615704
<button
616-
type="submit"
705+
type={isConfirmationOpen ? "button" : "submit"}
706+
onClick={isConfirmationOpen ? handleConfirm : undefined}
617707
disabled={!canSubmit}
618708
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
619709
>
620-
{isSubmitting ? "Depositing…" : "Add Liquidity"}
710+
{isSubmitting ? "Confirming…" : isConfirmationOpen ? "Confirm Deposit" : "Review Deposit"}
621711
</button>
622712
)}
623713
</div>

0 commit comments

Comments
 (0)