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