diff --git a/packages/liquidity-widget/README.md b/packages/liquidity-widget/README.md new file mode 100644 index 0000000..d2b6d75 --- /dev/null +++ b/packages/liquidity-widget/README.md @@ -0,0 +1,97 @@ +# Gooddollar Liquidity Web Component + +The `gooddollar-liquidity-widget` web component provides a simple and interactive way for users to provide liquidity to the G$/USDGLO pool on Celo and earn trading fees. + +## Integrating The Component + +Can be used in any website, for a quick setup: + +1. **Download the Script**: Download the `index.global.js` file from the project releases or build it from the source. +2. **Include in HTML**: Add the script to your HTML file. +3. **Add the Component**: Use the `` tag where you want it to appear. + +```html + + + + + + Gooddollar Liquidity + + + + + + + +``` + +## Configurable Options + +Customize the `gooddollar-liquidity-widget` using these properties: + +- **`connectWallet`**: _(Set via JavaScript property)_ + Defines the function called when the "Connect Wallet" button is clicked. + +- **`web3Provider`**: _(Set via JavaScript property)_ + The web3Provider object when the wallet is connected. Wallet connection logic should be handled outside of this component. + +- **`explorerBaseUrl`**: _(String, default: `"https://celoscan.io"`)_ + Base URL used for linking transaction hashes to a block explorer. + +- **`approvalBuffer`**: _(Number, default: `5`)_ + Percentage buffer added on top of the required amount when approving tokens, to account for minor price changes between approval and minting. + +- **`defaultRange`**: _(String, default: `"full"`)_ + The initially selected price range preset. Accepted values: `"full"`, `"wide"`, `"narrow"`. + +- **`showPositions`**: _(Boolean, default: `true`)_ + Whether to show the "My Positions" tab, which lists the user's existing liquidity positions. + +- **`theme`**: _(Object, optional)_ + Override visual styling. Accepts an object with any of the following keys: + - `primaryColor` – accent color for buttons and highlights (CSS color string). + - `borderRadius` – border radius of the widget container (CSS value, e.g. `"12px"`). + - `fontFamily` – font family used by the widget (CSS font-family string). + +## Events + +The widget dispatches custom DOM events that integrators can listen to: + +- **`lw-tx-submitted`** – Fired when a transaction hash is received from the wallet. Detail: `{ hash, step }`. +- **`lw-tx-confirmed`** – Fired when a transaction is confirmed on-chain. Detail: `{ hash, step }`. +- **`lw-tx-failed`** – Fired when a transaction fails. Detail: `{ hash, step, error }`. +- **`lw-position-added`** – Fired after a new liquidity position is successfully minted. Detail: `{ hash }`. + +```js +document.getElementById("liquidityWidget").addEventListener("lw-position-added", (e) => { + console.log("Position minted:", e.detail.hash); +}); +``` diff --git a/packages/liquidity-widget/package.json b/packages/liquidity-widget/package.json new file mode 100644 index 0000000..190efa5 --- /dev/null +++ b/packages/liquidity-widget/package.json @@ -0,0 +1,26 @@ +{ + "name": "@goodsdks/liquidity-widget", + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "yarn build-liquidity-widget", + "dev": "tsc --watch", + "build-liquidity-widget": "tsup --config tsup.config.liquidity.ts", + "bump-liquidity-widget": "yarn version patch && yarn build-liquidity-widget && git add package.json && git commit -m \"version bump\"" + }, + "main": "./dist/index.global.js", + "module": "./dist/index.js", + "devDependencies": { + "@repo/typescript-config": "workspace:*", + "tsup": "^8.3.5", + "typescript": "latest" + }, + "dependencies": { + "lit": "^3.1.0", + "viem": "latest" + }, + "files": [ + "dist", + "src" + ] +} diff --git a/packages/liquidity-widget/src/GooddollarLiquidityWidget.ts b/packages/liquidity-widget/src/GooddollarLiquidityWidget.ts new file mode 100644 index 0000000..3698897 --- /dev/null +++ b/packages/liquidity-widget/src/GooddollarLiquidityWidget.ts @@ -0,0 +1,689 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { createWalletClient, createPublicClient, custom, http, formatEther, parseEther } from 'viem'; +import type { PublicClient, WalletClient } from 'viem'; +import { celo } from 'viem/chains'; + +import { + GD_TOKEN, USDGLO_TOKEN, IS_GD_TOKEN0, + TICK_SPACING, DEFAULT_EXPLORER_URL, DEFAULT_APPROVAL_BUFFER, + FULL_RANGE_TICK_LOWER, FULL_RANGE_TICK_UPPER, + tickToSqrtPrice, +} from './liquidity/constants'; +import type { TxFlowPhase, TxStepInfo, PositionData, WidgetTheme } from './liquidity/types'; +import { loadPoolData, loadUserBalancesAndAllowances, getUserPositions, formatBigIntDisplay, formatAmount } from './liquidity/pool-service'; +import { approveToken, addLiquidity, parseTxError } from './liquidity/tx-service'; +import { RANGE_PRESETS } from './liquidity/components/lw-range-presets'; + +import './liquidity/components/lw-stepper'; +import './liquidity/components/lw-tooltip'; +import './liquidity/components/lw-range-presets'; +import './liquidity/components/lw-pool-info'; +import './liquidity/components/lw-tx-status'; +import './liquidity/components/lw-position-card'; +import './liquidity/components/lw-positions-panel'; + +type WidgetTab = 'add' | 'positions'; + +@customElement('gooddollar-liquidity-widget') +export class GooddollarLiquidityWidget extends LitElement { + + // ── Integrator Properties ────────────────────────────────────────── + + @property({ type: Object }) + web3Provider: any = null; + + @property({ type: Function }) + connectWallet: (() => void) | undefined = undefined; + + @property({ type: String }) + explorerBaseUrl: string = DEFAULT_EXPLORER_URL; + + @property({ type: Number }) + approvalBuffer: number = DEFAULT_APPROVAL_BUFFER; + + @property({ type: String }) + defaultRange: 'full' | 'wide' | 'narrow' = 'full'; + + @property({ type: Boolean }) + showPositions: boolean = true; + + @property({ type: Object }) + theme: Partial | undefined = undefined; + + // ── Internal State ───────────────────────────────────────────────── + + @state() private activeTab: WidgetTab = 'add'; + @state() private gdInput = ''; + @state() private usdgloInput = ''; + @state() private gdBalance = 0n; + @state() private usdgloBalance = 0n; + @state() private gdAllowance = 0n; + @state() private usdgloAllowance = 0n; + @state() private sqrtPriceX96 = 0n; + @state() private currentTick = 0; + @state() private isLoading = false; + @state() private inputError = ''; + + @state() private selectedRange: 'full' | 'wide' | 'narrow' = 'full'; + @state() private tickLower = FULL_RANGE_TICK_LOWER; + @state() private tickUpper = FULL_RANGE_TICK_UPPER; + + @state() private txPhase: TxFlowPhase = 'idle'; + @state() private txHash = ''; + @state() private txError = ''; + @state() private txSteps: TxStepInfo[] = []; + + @state() private positions: PositionData[] = []; + @state() private positionsLoading = false; + + private walletClient: WalletClient | null = null; + private publicClient: PublicClient | null = null; + private userAddress: string | null = null; + private refreshInterval: ReturnType | null = null; + + // ── Styles ───────────────────────────────────────────────────────── + + static styles = css` + :host { + display: block; + font-family: var(--lw-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); + max-width: 480px; + margin: 0 auto; + } + + .container { + background: white; + border-radius: var(--lw-radius, 16px); + padding: 24px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border: 1px solid #e5e7eb; + position: relative; + } + + .header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; + } + + .logo { width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; } + .logo img { width: 44px; height: 44px; box-shadow: rgba(0,0,0,0.075) 0px 6px 10px; border-radius: 50%; background: white; } + + .title { font-size: 20px; font-weight: 600; color: #111827; margin: 0; flex: 1; } + .title-row { display: flex; align-items: center; gap: 4px; } + + /* Tabs */ + .tabs { + display: flex; + background: #f3f4f6; + border-radius: 10px; + padding: 3px; + margin-bottom: 16px; + } + .tab { + flex: 1; + text-align: center; + padding: 8px 0; + font-size: 14px; + font-weight: 600; + color: #6b7280; + border-radius: 8px; + border: none; + background: transparent; + cursor: pointer; + transition: all 0.15s; + } + .tab.active { background: white; color: #111827; box-shadow: 0 1px 2px rgba(0,0,0,0.06); } + .tab:hover:not(.active) { color: #374151; } + + /* Input sections */ + .input-section { + background: #f9fafb; + border-radius: 12px; + padding: 16px; + margin-bottom: 12px; + border: 1px solid #e5e7eb; + } + .input-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } + .token-name { font-size: 15px; font-weight: 600; color: #111827; display: flex; align-items: center; } + .balance-info { font-size: 13px; color: #6b7280; } + .input-container { display: flex; align-items: center; gap: 12px; } + .amount-input { + flex: 1; border: none; background: transparent; + font-size: 22px; font-weight: 600; color: #111827; outline: none; + min-width: 0; + } + .max-button { + background: #fff; border-radius: 8px; padding: 5px 10px; + font-size: 12px; font-weight: 500; color: #374151; + border: 1px solid #d1d5db; cursor: pointer; transition: all 0.15s; + } + .max-button:hover { border-color: var(--lw-primary, #00b0ff); color: var(--lw-primary, #00b0ff); } + + /* Buttons */ + .main-button { + width: 100%; + background: var(--lw-primary, #00b0ff); + border: none; + border-radius: 12px; + padding: 14px; + font-size: 16px; + font-weight: 600; + color: #fff; + cursor: pointer; + transition: background 0.15s; + box-shadow: rgba(11,27,102,0.2) 2px 2px 8px -1px; + } + .main-button:hover { filter: brightness(0.92); } + .main-button:disabled { opacity: 0.55; cursor: not-allowed; filter: none; } + + .error-message { color: #dc2626; font-size: 12px; margin-bottom: 12px; text-align: center; } + .hidden { display: none; } + + .overlay { + position: absolute; inset: 0; + background: rgba(255,255,255,0.3); + border-radius: var(--lw-radius, 16px); + z-index: 10; + pointer-events: none; + } + `; + + // ── Lifecycle ────────────────────────────────────────────────────── + + connectedCallback(): void { + super.connectedCallback(); + this.selectedRange = this.defaultRange; + this.refreshInterval = setInterval(() => this.refreshData(), 30_000); + this.refreshData(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this.refreshInterval) clearInterval(this.refreshInterval); + } + + updated(changed: Map) { + if (changed.has('web3Provider')) this.refreshData(); + if (changed.has('theme')) this._applyTheme(); + } + + // ── Render ───────────────────────────────────────────────────────── + + render() { + const isConnected = !!(this.web3Provider && this.web3Provider.isConnected && this.userAddress); + const gdPrice = this._getGdPriceInUsdglo(); + + return html` +
+ +
+ +
+

G$ / USDGLO Liquidity

+ +
+
+ + + + + + ${this.showPositions && isConnected ? html` +
+ + +
+ ` : nothing} + + + ${this.activeTab === 'add' ? this._renderAddTab(isConnected) : this._renderPositionsTab()} + + ${this.txPhase !== 'idle' && this.txPhase !== 'success' && this.txPhase !== 'error' + ? html`
` : nothing} +
+ `; + } + + private _renderAddTab(isConnected: boolean) { + const showStepper = this.txPhase !== 'idle' && this.txSteps.length > 0; + + return html` + + + + +
+
+ + G$ + + + + Balance: ${this.isLoading ? 'Loading...' : formatBigIntDisplay(this.gdBalance)} + +
+
+ + +
+
+ + +
+
+ + USDGLO + + + + Balance: ${this.isLoading ? 'Loading...' : formatBigIntDisplay(this.usdgloBalance)} + +
+
+ + +
+
+ + ${this.inputError ? html`
${this.inputError}
` : nothing} + + + + + + ${showStepper ? html`` : nothing} + + + ${!isConnected + ? html`` + : html` + + ` + } + `; + } + + private _renderPositionsTab() { + return html` + + `; + } + + // ── Theme ────────────────────────────────────────────────────────── + + private _applyTheme() { + if (!this.theme) return; + if (this.theme.primaryColor) + this.style.setProperty('--lw-primary', this.theme.primaryColor); + if (this.theme.borderRadius) + this.style.setProperty('--lw-radius', this.theme.borderRadius); + if (this.theme.fontFamily) + this.style.setProperty('--lw-font', this.theme.fontFamily); + } + + // ── Data Loading ─────────────────────────────────────────────────── + + private async refreshData() { + if (!this.publicClient) { + this.publicClient = createPublicClient({ + chain: celo, + transport: http(), + }) as unknown as PublicClient; + } + + this.isLoading = true; + + try { + const pool = await loadPoolData(this.publicClient); + this.sqrtPriceX96 = pool.sqrtPriceX96; + this.currentTick = pool.currentTick; + } catch (e) { + console.error('Error loading pool data:', e); + } + + try { + if (this.web3Provider && this.web3Provider.isConnected) { + this.walletClient = createWalletClient({ chain: celo, transport: custom(this.web3Provider) }); + const accounts = await this.web3Provider.request({ method: 'eth_accounts' }); + if (accounts.length > 0) { + this.userAddress = accounts[0]; + const data = await loadUserBalancesAndAllowances(this.publicClient, this.userAddress as `0x${string}`); + this.gdBalance = data.gdBalance; + this.usdgloBalance = data.usdgloBalance; + this.gdAllowance = data.gdAllowance; + this.usdgloAllowance = data.usdgloAllowance; + } else { + this._resetUserData(); + } + } else { + this._resetUserData(); + } + } catch (e) { + console.error('Error loading user data:', e); + } + + this.isLoading = false; + } + + private _resetUserData() { + this.userAddress = null; + this.gdBalance = 0n; + this.usdgloBalance = 0n; + this.gdAllowance = 0n; + this.usdgloAllowance = 0n; + this.positions = []; + } + + private async _loadPositions() { + if (!this.publicClient || !this.userAddress) return; + this.positionsLoading = true; + try { + this.positions = await getUserPositions( + this.publicClient, + this.userAddress as `0x${string}`, + this.currentTick, + ); + } catch (e) { + console.error('Error loading positions:', e); + } + this.positionsLoading = false; + } + + // ── Price & Amount Math ──────────────────────────────────────────── + + private _getGdPriceInUsdglo(): number { + if (this.sqrtPriceX96 === 0n) return 0; + const sqrtPrice = Number(this.sqrtPriceX96) / (2 ** 96); + const rawPrice = sqrtPrice * sqrtPrice; + return IS_GD_TOKEN0 ? rawPrice : 1 / rawPrice; + } + + private _getSqrtPriceFloat(): number { + return Number(this.sqrtPriceX96) / (2 ** 96); + } + + private _calcAmount1From0(amount0: number): number { + const sqC = this._getSqrtPriceFloat(); + const sqL = tickToSqrtPrice(this.tickLower); + const sqU = tickToSqrtPrice(this.tickUpper); + if (sqU <= sqL || sqC <= sqL || sqC >= sqU) return 0; + const L = amount0 * sqC * sqU / (sqU - sqC); + return L * (sqC - sqL); + } + + private _calcAmount0From1(amount1: number): number { + const sqC = this._getSqrtPriceFloat(); + const sqL = tickToSqrtPrice(this.tickLower); + const sqU = tickToSqrtPrice(this.tickUpper); + if (sqU <= sqL || sqC >= sqU || sqC <= sqL) return 0; + const L = amount1 / (sqC - sqL); + return L * (sqU - sqC) / (sqC * sqU); + } + + private _calcUsdgloFromGd() { + const gd = parseFloat(this.gdInput); + if (isNaN(gd) || gd <= 0 || this.sqrtPriceX96 === 0n) { this.usdgloInput = ''; return; } + const usdglo = IS_GD_TOKEN0 ? this._calcAmount1From0(gd) : this._calcAmount0From1(gd); + if (isFinite(usdglo) && usdglo >= 0) this.usdgloInput = formatAmount(usdglo); + } + + private _calcGdFromUsdglo() { + const u = parseFloat(this.usdgloInput); + if (isNaN(u) || u <= 0 || this.sqrtPriceX96 === 0n) { this.gdInput = ''; return; } + const gd = IS_GD_TOKEN0 ? this._calcAmount0From1(u) : this._calcAmount1From0(u); + if (isFinite(gd) && gd >= 0) this.gdInput = formatAmount(gd); + } + + private _safeParseEther(value: string): bigint { + try { + if (!value || value.trim() === '' || value === '.') return 0n; + return parseEther(value); + } catch { return 0n; } + } + + // ── Validation ───────────────────────────────────────────────────── + + private _validateInputs(force = false) { + this.inputError = ''; + const re = /^[0-9]*\.?[0-9]*$/; + if (this.gdInput && !re.test(this.gdInput)) { this.inputError = 'Invalid G$ value'; return; } + if (this.usdgloInput && !re.test(this.usdgloInput)) { this.inputError = 'Invalid USDGLO value'; return; } + + const gdNum = parseFloat(this.gdInput || '0'); + const usdgloNum = parseFloat(this.usdgloInput || '0'); + + if (force && (isNaN(gdNum) || gdNum <= 0 || isNaN(usdgloNum) || usdgloNum <= 0)) { + this.inputError = 'Please enter valid amounts'; + return; + } + if (gdNum > 0 && this._safeParseEther(this.gdInput) > this.gdBalance) { + this.inputError = 'Insufficient G$ balance'; + return; + } + if (usdgloNum > 0 && this._safeParseEther(this.usdgloInput) > this.usdgloBalance) { + this.inputError = 'Insufficient USDGLO balance'; + return; + } + } + + private _getButtonLabel(): string { + if (this.txPhase === 'success') return 'Add More Liquidity'; + + const gdWei = this._safeParseEther(this.gdInput); + const usdgloWei = this._safeParseEther(this.usdgloInput); + + if (gdWei > 0n && this.gdAllowance < gdWei) return 'Approve & Add Liquidity'; + if (usdgloWei > 0n && this.usdgloAllowance < usdgloWei) return 'Approve & Add Liquidity'; + return 'Add Liquidity'; + } + + // ── Event Handlers ───────────────────────────────────────────────── + + private _handleConnect() { this.connectWallet?.(); } + + private _handleGdInput(e: Event) { + this.gdInput = (e.target as HTMLInputElement).value; + this._calcUsdgloFromGd(); + this._validateInputs(); + } + + private _handleUsdgloInput(e: Event) { + this.usdgloInput = (e.target as HTMLInputElement).value; + this._calcGdFromUsdglo(); + this._validateInputs(); + } + + private _handleGdMax() { + this.gdInput = Number(formatEther(this.gdBalance)).toString(); + this._calcUsdgloFromGd(); + this.inputError = ''; + } + + private _handleUsdgloMax() { + this.usdgloInput = Number(formatEther(this.usdgloBalance)).toString(); + this._calcGdFromUsdglo(); + this.inputError = ''; + } + + private _onRangeChange(e: CustomEvent) { + const id = e.detail.id as 'full' | 'wide' | 'narrow'; + this.selectedRange = id; + const preset = RANGE_PRESETS.find(p => p.id === id)!; + const { tickLower, tickUpper } = preset.getTickRange(this.currentTick, TICK_SPACING); + this.tickLower = tickLower; + this.tickUpper = tickUpper; + this._calcUsdgloFromGd(); + } + + private _handleRetry() { + this.txPhase = 'idle'; + this.txHash = ''; + this.txError = ''; + this.txSteps = []; + } + + // ── Main Transaction Flow ────────────────────────────────────────── + + async _handleMainAction() { + if (!this.walletClient || !this.publicClient || !this.userAddress) return; + + if (this.txPhase === 'success') { + this.txPhase = 'idle'; + this.txHash = ''; + this.txSteps = []; + return; + } + + this._validateInputs(true); + if (this.inputError) return; + + const account = this.userAddress as `0x${string}`; + const gdWei = this._safeParseEther(this.gdInput); + const usdgloWei = this._safeParseEther(this.usdgloInput); + + const needGdApproval = gdWei > 0n && this.gdAllowance < gdWei; + const needUsdgloApproval = usdgloWei > 0n && this.usdgloAllowance < usdgloWei; + + const steps: TxStepInfo[] = [ + { label: 'Approve G$', status: needGdApproval ? 'pending' : 'skipped' }, + { label: 'Approve USDGLO', status: needUsdgloApproval ? 'pending' : 'skipped' }, + { label: 'Add Liquidity', status: 'pending' }, + ]; + this.txSteps = [...steps]; + this.txError = ''; + + try { + // Step 1: Approve G$ + if (needGdApproval) { + steps[0].status = 'active'; + this.txSteps = [...steps]; + this.txPhase = 'approving-gd'; + this.txHash = ''; + + const hash = await approveToken( + this.publicClient, this.walletClient, account, + GD_TOKEN, gdWei, this.approvalBuffer, + { + onHash: (h) => { this.txHash = h; this._emitTxEvent('lw-tx-submitted', h, 'approve-gd'); }, + onConfirmed: (h) => this._emitTxEvent('lw-tx-confirmed', h, 'approve-gd'), + }, + ); + + steps[0].status = 'completed'; + steps[0].txHash = hash; + this.txSteps = [...steps]; + await this.refreshData(); + } + + // Step 2: Approve USDGLO + if (needUsdgloApproval) { + steps[1].status = 'active'; + this.txSteps = [...steps]; + this.txPhase = 'approving-usdglo'; + this.txHash = ''; + + const hash = await approveToken( + this.publicClient, this.walletClient, account, + USDGLO_TOKEN, usdgloWei, this.approvalBuffer, + { + onHash: (h) => { this.txHash = h; this._emitTxEvent('lw-tx-submitted', h, 'approve-usdglo'); }, + onConfirmed: (h) => this._emitTxEvent('lw-tx-confirmed', h, 'approve-usdglo'), + }, + ); + + steps[1].status = 'completed'; + steps[1].txHash = hash; + this.txSteps = [...steps]; + await this.refreshData(); + } + + // Step 3: Mint + steps[2].status = 'active'; + this.txSteps = [...steps]; + this.txPhase = 'minting'; + this.txHash = ''; + + const hash = await addLiquidity( + this.publicClient, this.walletClient, account, + gdWei, usdgloWei, this.tickLower, this.tickUpper, + { + onHash: (h) => { this.txHash = h; this._emitTxEvent('lw-tx-submitted', h, 'mint'); }, + onConfirmed: (h) => this._emitTxEvent('lw-tx-confirmed', h, 'mint'), + }, + ); + + steps[2].status = 'completed'; + steps[2].txHash = hash; + this.txSteps = [...steps]; + this.txPhase = 'success'; + this.txHash = hash; + + this._emitEvent('lw-position-added', { hash }); + + this.gdInput = ''; + this.usdgloInput = ''; + this.inputError = ''; + await this.refreshData(); + + } catch (error: any) { + this.txPhase = 'error'; + this.txError = parseTxError(error); + this._emitTxEvent('lw-tx-failed', this.txHash, this.txPhase, error); + } + } + + // ── Custom Events for Integrators ────────────────────────────────── + + private _emitEvent(name: string, detail: Record) { + this.dispatchEvent(new CustomEvent(name, { + detail, bubbles: true, composed: true, + })); + } + + private _emitTxEvent(name: string, hash: string, step: string, error?: any) { + this._emitEvent(name, { hash, step, ...(error ? { error: parseTxError(error) } : {}) }); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gooddollar-liquidity-widget': GooddollarLiquidityWidget; + } +} diff --git a/packages/liquidity-widget/src/index.ts b/packages/liquidity-widget/src/index.ts new file mode 100644 index 0000000..f2c6997 --- /dev/null +++ b/packages/liquidity-widget/src/index.ts @@ -0,0 +1 @@ +export * from "./GooddollarLiquidityWidget" diff --git a/packages/liquidity-widget/src/liquidity/components/lw-pool-info.ts b/packages/liquidity-widget/src/liquidity/components/lw-pool-info.ts new file mode 100644 index 0000000..648c4ad --- /dev/null +++ b/packages/liquidity-widget/src/liquidity/components/lw-pool-info.ts @@ -0,0 +1,103 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { POOL_ADDRESS, POOL_FEE } from '../constants'; + +import './lw-tooltip'; + +@customElement('lw-pool-info') +export class LwPoolInfo extends LitElement { + static styles = css` + :host { display: block; margin-bottom: 16px; } + + .pool-info { + background: #f0f9ff; + border: 1px solid #bae6fd; + border-radius: 10px; + padding: 12px 14px; + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + } + + .info-item { + display: flex; + flex-direction: column; + gap: 2px; + } + + .info-label { + font-size: 11px; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.03em; + display: flex; + align-items: center; + } + + .info-value { + font-size: 14px; + font-weight: 600; + color: #111827; + } + + .pool-link { + font-size: 12px; + color: var(--lw-primary, #00b0ff); + text-decoration: none; + font-weight: 500; + } + .pool-link:hover { text-decoration: underline; } + `; + + @property({ type: Number }) + gdPrice: number = 0; + + @property({ type: Boolean }) + loading: boolean = false; + + @property({ type: String }) + explorerUrl: string = 'https://celoscan.io'; + + render() { + const feeTier = `${POOL_FEE / 10_000}%`; + const priceDisplay = this.loading + ? 'Loading...' + : this.gdPrice > 0 + ? `${(1000 * this.gdPrice).toFixed(4)} USDGLO` + : '...'; + + return html` +
+
+ Pool + + + G$ / USDGLO +
+
+ Fee Tier + ${feeTier} +
+
+ Price (1,000 G$) + ${priceDisplay} +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'lw-pool-info': LwPoolInfo; + } +} diff --git a/packages/liquidity-widget/src/liquidity/components/lw-position-card.ts b/packages/liquidity-widget/src/liquidity/components/lw-position-card.ts new file mode 100644 index 0000000..111fd67 --- /dev/null +++ b/packages/liquidity-widget/src/liquidity/components/lw-position-card.ts @@ -0,0 +1,179 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { formatEther } from 'viem'; +import type { PositionData } from '../types'; +import { IS_GD_TOKEN0 } from '../constants'; + +const UBESWAP_POOL_BASE = 'https://app.ubeswap.org/#/pools'; + +@customElement('lw-position-card') +export class LwPositionCard extends LitElement { + static styles = css` + :host { display: block; } + + .card { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 14px; + margin-bottom: 10px; + transition: border-color 0.15s; + } + .card:hover { border-color: #d1d5db; } + + .card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + } + + .token-id { + font-size: 12px; + color: #6b7280; + font-weight: 500; + } + + .range-badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 12px; + } + .range-badge.in-range { background: #d1fae5; color: #065f46; } + .range-badge.out-of-range { background: #fef3c7; color: #92400e; } + + .amounts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 10px; + } + + .amount-item { + display: flex; + flex-direction: column; + gap: 1px; + } + .amount-label { + font-size: 11px; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.03em; + } + .amount-value { + font-size: 14px; + font-weight: 600; + color: #111827; + } + + .fees-row { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 8px; + border-top: 1px solid #e5e7eb; + } + .fees-label { + font-size: 12px; + color: #6b7280; + } + .fees-value { + font-size: 13px; + font-weight: 600; + color: #059669; + } + + .actions { + display: flex; + gap: 8px; + margin-top: 10px; + } + + .manage-link { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: background 0.15s; + background: var(--lw-primary, #00b0ff); + color: white; + } + .manage-link:hover { filter: brightness(0.92); } + .manage-link .arrow { margin-left: 6px; font-size: 14px; } + `; + + @property({ type: Object }) + position!: PositionData; + + @property({ type: String }) + explorerUrl: string = 'https://celoscan.io'; + + private _fmt(val: bigint): string { + const n = Number(formatEther(val)); + if (n === 0) return '0'; + if (n < 0.0001) return '<0.0001'; + return Intl.NumberFormat(undefined, { maximumFractionDigits: 4 }).format(n); + } + + render() { + const p = this.position; + if (!p) return html``; + + const gdAmount = IS_GD_TOKEN0 ? p.amount0 : p.amount1; + const usdgloAmount = IS_GD_TOKEN0 ? p.amount1 : p.amount0; + const gdFees = IS_GD_TOKEN0 ? p.tokensOwed0 : p.tokensOwed1; + const usdgloFees = IS_GD_TOKEN0 ? p.tokensOwed1 : p.tokensOwed0; + const manageUrl = `${UBESWAP_POOL_BASE}/${p.tokenId.toString()}`; + + return html` +
+
+ Position #${p.tokenId.toString()} + + ${p.inRange ? 'In Range' : 'Out of Range'} + +
+ +
+
+ G$ + ${this._fmt(gdAmount)} +
+
+ USDGLO + ${this._fmt(usdgloAmount)} +
+
+ +
+ Unclaimed Fees + + ${this._fmt(gdFees)} G$ / ${this._fmt(usdgloFees)} USDGLO + +
+ + +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'lw-position-card': LwPositionCard; + } +} diff --git a/packages/liquidity-widget/src/liquidity/components/lw-positions-panel.ts b/packages/liquidity-widget/src/liquidity/components/lw-positions-panel.ts new file mode 100644 index 0000000..ee9a131 --- /dev/null +++ b/packages/liquidity-widget/src/liquidity/components/lw-positions-panel.ts @@ -0,0 +1,149 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { formatEther } from 'viem'; +import type { PositionData } from '../types'; +import { IS_GD_TOKEN0 } from '../constants'; + +import './lw-position-card'; +import './lw-tooltip'; + +@customElement('lw-positions-panel') +export class LwPositionsPanel extends LitElement { + static styles = css` + :host { display: block; } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 14px; + } + + .header-title { + font-size: 16px; + font-weight: 600; + color: #111827; + display: flex; + align-items: center; + } + + .count-badge { + font-size: 11px; + font-weight: 600; + background: var(--lw-primary, #00b0ff); + color: white; + border-radius: 10px; + padding: 1px 7px; + margin-left: 8px; + } + + .summary { + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 10px; + padding: 12px 14px; + margin-bottom: 14px; + display: flex; + gap: 16px; + flex-wrap: wrap; + } + + .summary-item { + display: flex; + flex-direction: column; + gap: 1px; + } + .summary-label { font-size: 11px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.03em; } + .summary-value { font-size: 14px; font-weight: 600; color: #065f46; } + + .empty { + text-align: center; + color: #9ca3af; + font-size: 14px; + padding: 24px 0; + } + + .loading { + text-align: center; + color: #6b7280; + font-size: 14px; + padding: 24px 0; + } + `; + + @property({ type: Array }) + positions: PositionData[] = []; + + @property({ type: Boolean }) + loading: boolean = false; + + @property({ type: String }) + explorerUrl: string = 'https://celoscan.io'; + + private _fmt(val: bigint): string { + const n = Number(formatEther(val)); + if (n === 0) return '0'; + return Intl.NumberFormat(undefined, { maximumFractionDigits: 4 }).format(n); + } + + render() { + if (this.loading) { + return html`
Loading positions...
`; + } + + const totalGd = this.positions.reduce((sum, p) => + sum + (IS_GD_TOKEN0 ? p.amount0 : p.amount1), 0n); + const totalUsdglo = this.positions.reduce((sum, p) => + sum + (IS_GD_TOKEN0 ? p.amount1 : p.amount0), 0n); + const totalGdFees = this.positions.reduce((sum, p) => + sum + (IS_GD_TOKEN0 ? p.tokensOwed0 : p.tokensOwed1), 0n); + const totalUsdgloFees = this.positions.reduce((sum, p) => + sum + (IS_GD_TOKEN0 ? p.tokensOwed1 : p.tokensOwed0), 0n); + + return html` +
+ + My Positions + + ${this.positions.length > 0 ? html`${this.positions.length}` : ''} + +
+ + ${this.positions.length === 0 ? html` +
No open positions in this pool.
+ ` : html` +
+
+ Total G$ + ${this._fmt(totalGd)} +
+
+ Total USDGLO + ${this._fmt(totalUsdglo)} +
+
+ Unclaimed G$ Fees + ${this._fmt(totalGdFees)} +
+
+ Unclaimed USDGLO Fees + ${this._fmt(totalUsdgloFees)} +
+
+ + ${this.positions.map(p => html` + + `)} + `} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'lw-positions-panel': LwPositionsPanel; + } +} diff --git a/packages/liquidity-widget/src/liquidity/components/lw-range-presets.ts b/packages/liquidity-widget/src/liquidity/components/lw-range-presets.ts new file mode 100644 index 0000000..f3db142 --- /dev/null +++ b/packages/liquidity-widget/src/liquidity/components/lw-range-presets.ts @@ -0,0 +1,145 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import type { RangePreset } from '../types'; +import { + FULL_RANGE_TICK_LOWER, FULL_RANGE_TICK_UPPER, TICK_SPACING, + POOL_FEE, nearestUsableTick, +} from '../constants'; + +import './lw-tooltip'; + +export const RANGE_PRESETS: RangePreset[] = [ + { + id: 'full', + label: 'Full Range', + description: 'Max flexibility, lower capital efficiency', + tooltip: 'Covers the entire price range. Your liquidity is always active but spread thin, earning lower fees per dollar deposited.', + concentrationMultiplier: 1, + getTickRange: () => ({ + tickLower: FULL_RANGE_TICK_LOWER, + tickUpper: FULL_RANGE_TICK_UPPER, + }), + }, + { + id: 'wide', + label: 'Wide', + description: 'Balanced range around current price', + tooltip: 'Concentrates liquidity in a wide band around the current price. Good balance of fee earnings and rebalancing frequency.', + concentrationMultiplier: 4, + getTickRange: (currentTick: number, tickSpacing: number) => ({ + tickLower: nearestUsableTick(currentTick - 120 * tickSpacing, tickSpacing), + tickUpper: nearestUsableTick(currentTick + 120 * tickSpacing, tickSpacing), + }), + }, + { + id: 'narrow', + label: 'Narrow', + description: 'Tight range, higher yield, more risk', + tooltip: 'Concentrates liquidity tightly around the current price for maximum fee earnings. Higher impermanent loss risk and may go out of range if price moves significantly.', + concentrationMultiplier: 10, + getTickRange: (currentTick: number, tickSpacing: number) => ({ + tickLower: nearestUsableTick(currentTick - 30 * tickSpacing, tickSpacing), + tickUpper: nearestUsableTick(currentTick + 30 * tickSpacing, tickSpacing), + }), + }, +]; + +@customElement('lw-range-presets') +export class LwRangePresets extends LitElement { + static styles = css` + :host { display: block; margin-bottom: 16px; } + + .label { + font-size: 13px; + font-weight: 600; + color: #374151; + margin-bottom: 8px; + display: flex; + align-items: center; + } + + .presets { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + } + + .preset-card { + background: #f9fafb; + border: 2px solid #e5e7eb; + border-radius: 10px; + padding: 10px 8px; + cursor: pointer; + text-align: center; + transition: border-color 0.15s, background 0.15s; + } + .preset-card:hover { border-color: #d1d5db; } + .preset-card.selected { + border-color: var(--lw-primary, #00b0ff); + background: rgba(0, 176, 255, 0.06); + } + + .preset-name { + font-size: 13px; + font-weight: 600; + color: #111827; + margin-bottom: 2px; + } + + .preset-desc { + font-size: 11px; + color: #6b7280; + line-height: 1.3; + margin-bottom: 4px; + } + + .preset-yield { + font-size: 12px; + font-weight: 600; + color: #059669; + } + `; + + @property({ type: String }) + selected: 'full' | 'wide' | 'narrow' = 'full'; + + @property({ type: Number }) + baseApr: number = 0; + + render() { + return html` +
+ Range Strategy + +
+
+ ${RANGE_PRESETS.map(preset => html` +
this._select(preset.id)} + > +
${preset.label}
+
${preset.description}
+ ${this.baseApr > 0 ? html` +
~${(this.baseApr * preset.concentrationMultiplier).toFixed(1)}% APR
+ ` : ''} +
+ `)} +
+ `; + } + + private _select(id: 'full' | 'wide' | 'narrow') { + this.selected = id; + this.dispatchEvent(new CustomEvent('range-change', { + detail: { id }, + bubbles: true, composed: true, + })); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'lw-range-presets': LwRangePresets; + } +} diff --git a/packages/liquidity-widget/src/liquidity/components/lw-stepper.ts b/packages/liquidity-widget/src/liquidity/components/lw-stepper.ts new file mode 100644 index 0000000..9680d04 --- /dev/null +++ b/packages/liquidity-widget/src/liquidity/components/lw-stepper.ts @@ -0,0 +1,112 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import type { TxStepInfo } from '../types'; + +@customElement('lw-stepper') +export class LwStepper extends LitElement { + static styles = css` + :host { display: block; margin-bottom: 16px; } + + .stepper { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + padding: 12px 0; + } + + .step { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #9ca3af; + white-space: nowrap; + } + + .step.active { color: var(--lw-primary, #00b0ff); font-weight: 600; } + .step.completed { color: #10b981; } + .step.skipped { color: #d1d5db; text-decoration: line-through; } + + .step-circle { + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + flex-shrink: 0; + border: 2px solid #e5e7eb; + background: white; + color: #9ca3af; + } + + .step.active .step-circle { + border-color: var(--lw-primary, #00b0ff); + background: var(--lw-primary, #00b0ff); + color: white; + } + + .step.completed .step-circle { + border-color: #10b981; + background: #10b981; + color: white; + } + + .step.skipped .step-circle { + border-color: #e5e7eb; + background: #f3f4f6; + color: #d1d5db; + } + + .connector { + width: 32px; + height: 2px; + background: #e5e7eb; + margin: 0 4px; + flex-shrink: 0; + } + + .connector.done { background: #10b981; } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + .step.active .step-circle { animation: pulse 1.5s ease-in-out infinite; } + `; + + @property({ type: Array }) + steps: TxStepInfo[] = []; + + render() { + return html` +
+ ${this.steps.map((step, i) => html` + ${i > 0 ? html`
` : ''} +
+
+ ${step.status === 'completed' ? html`✓` : + step.status === 'skipped' ? html`—` : + html`${i + 1}`} +
+ ${step.label} +
+ `)} +
+ `; + } + + private _connectorDone(index: number): boolean { + const prev = this.steps[index - 1]; + return prev?.status === 'completed' || prev?.status === 'skipped'; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'lw-stepper': LwStepper; + } +} diff --git a/packages/liquidity-widget/src/liquidity/components/lw-tooltip.ts b/packages/liquidity-widget/src/liquidity/components/lw-tooltip.ts new file mode 100644 index 0000000..529a3ac --- /dev/null +++ b/packages/liquidity-widget/src/liquidity/components/lw-tooltip.ts @@ -0,0 +1,97 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +@customElement('lw-tooltip') +export class LwTooltip extends LitElement { + static styles = css` + :host { + display: inline-flex; + position: relative; + vertical-align: middle; + } + + .trigger { + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + background: #e5e7eb; + color: #6b7280; + font-size: 10px; + font-weight: 700; + line-height: 1; + border: none; + padding: 0; + margin-left: 4px; + transition: background 0.15s; + } + .trigger:hover { background: #d1d5db; } + + .popover { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: #1f2937; + color: #f9fafb; + font-size: 12px; + line-height: 1.5; + padding: 8px 12px; + border-radius: 8px; + width: max-content; + max-width: 260px; + z-index: 100; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; + } + + .popover.visible { + opacity: 1; + pointer-events: auto; + } + + .popover::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: #1f2937; + } + `; + + @property({ type: String }) + text = ''; + + @state() + private _visible = false; + + render() { + return html` + +
${this.text}
+ `; + } + + private _show() { this._visible = true; } + private _hide() { this._visible = false; } + private _toggle() { this._visible = !this._visible; } +} + +declare global { + interface HTMLElementTagNameMap { + 'lw-tooltip': LwTooltip; + } +} diff --git a/packages/liquidity-widget/src/liquidity/components/lw-tx-status.ts b/packages/liquidity-widget/src/liquidity/components/lw-tx-status.ts new file mode 100644 index 0000000..2e36225 --- /dev/null +++ b/packages/liquidity-widget/src/liquidity/components/lw-tx-status.ts @@ -0,0 +1,140 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import type { TxFlowPhase } from '../types'; + +@customElement('lw-tx-status') +export class LwTxStatus extends LitElement { + static styles = css` + :host { display: block; margin-bottom: 12px; } + + .status-banner { + border-radius: 10px; + padding: 12px 14px; + font-size: 13px; + line-height: 1.5; + display: flex; + align-items: flex-start; + gap: 10px; + } + + .status-banner.info { + background: #eff6ff; + border: 1px solid #bfdbfe; + color: #1e40af; + } + .status-banner.success { + background: #ecfdf5; + border: 1px solid #a7f3d0; + color: #065f46; + } + .status-banner.error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + } + + .status-icon { font-size: 16px; flex-shrink: 0; margin-top: 1px; } + .status-body { flex: 1; } + .status-body a { + color: inherit; + font-weight: 600; + text-decoration: underline; + } + + .retry-btn { + margin-top: 8px; + background: #991b1b; + color: white; + border: none; + border-radius: 6px; + padding: 6px 14px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + } + .retry-btn:hover { background: #7f1d1d; } + + @keyframes spin { + to { transform: rotate(360deg); } + } + .spinner { + width: 16px; + height: 16px; + border: 2px solid #bfdbfe; + border-top-color: #1e40af; + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; + margin-top: 1px; + } + `; + + @property({ type: String }) + phase: TxFlowPhase = 'idle'; + + @property({ type: String }) + txHash: string = ''; + + @property({ type: String }) + errorMessage: string = ''; + + @property({ type: String }) + explorerUrl: string = 'https://celoscan.io'; + + render() { + if (this.phase === 'idle') return html``; + + if (this.phase === 'error') { + return html` +
+ +
+ ${this.errorMessage || 'Transaction failed.'} +
+ +
+
+ `; + } + + if (this.phase === 'success') { + return html` +
+ +
+ Liquidity added successfully! + ${this.txHash ? html` +
View on Explorer + ` : ''} +
+
+ `; + } + + const stepLabel = this.phase === 'approving-gd' ? 'Approving G$...' + : this.phase === 'approving-usdglo' ? 'Approving USDGLO...' + : 'Adding liquidity...'; + + return html` +
+
+
+ ${stepLabel} + ${this.txHash ? html` +
View transaction + ` : ''} +
+
+ `; + } + + private _retry() { + this.dispatchEvent(new CustomEvent('retry', { bubbles: true, composed: true })); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'lw-tx-status': LwTxStatus; + } +} diff --git a/packages/liquidity-widget/src/liquidity/constants.ts b/packages/liquidity-widget/src/liquidity/constants.ts new file mode 100644 index 0000000..1a9f991 --- /dev/null +++ b/packages/liquidity-widget/src/liquidity/constants.ts @@ -0,0 +1,152 @@ +import { parseAbi } from 'viem'; + +export const GD_TOKEN = '0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A' as const; +export const USDGLO_TOKEN = '0x4F604735c1cF31399C6E711D5962b2B3E0225AD3' as const; +export const POSITION_MANAGER = '0x897387c7B996485c3AAa85c94272Cd6C506f8c8F' as const; +export const POOL_ADDRESS = '0x3D9e27C04076288eBfdC4815b4f6d81b0ED1b341' as const; +export const POOL_FEE = 10_000; +export const TICK_SPACING = 200; +export const DEFAULT_EXPLORER_URL = 'https://celoscan.io'; +export const DEFAULT_APPROVAL_BUFFER = 5; + +export const IS_GD_TOKEN0 = GD_TOKEN.toLowerCase() < USDGLO_TOKEN.toLowerCase(); +export const TOKEN0 = IS_GD_TOKEN0 ? GD_TOKEN : USDGLO_TOKEN; +export const TOKEN1 = IS_GD_TOKEN0 ? USDGLO_TOKEN : GD_TOKEN; + +export const FULL_RANGE_TICK_LOWER = -887_200; +export const FULL_RANGE_TICK_UPPER = 887_200; + +export const ERC20_ABI = parseAbi([ + 'function balanceOf(address account) view returns (uint256)', + 'function allowance(address owner, address spender) view returns (uint256)', + 'function approve(address spender, uint256 amount) returns (bool)', +]); + +export const POOL_ABI = parseAbi([ + 'function slot0() view returns (uint160 sqrtPriceX96, int24 tick, uint16 observationIndex, uint16 observationCardinality, uint16 observationCardinalityNext, uint8 feeProtocol, bool unlocked)', +]); + +export const POSITION_MANAGER_ABI = [ + { + inputs: [ + { + components: [ + { internalType: 'address', name: 'token0', type: 'address' }, + { internalType: 'address', name: 'token1', type: 'address' }, + { internalType: 'uint24', name: 'fee', type: 'uint24' }, + { internalType: 'int24', name: 'tickLower', type: 'int24' }, + { internalType: 'int24', name: 'tickUpper', type: 'int24' }, + { internalType: 'uint256', name: 'amount0Desired', type: 'uint256' }, + { internalType: 'uint256', name: 'amount1Desired', type: 'uint256' }, + { internalType: 'uint256', name: 'amount0Min', type: 'uint256' }, + { internalType: 'uint256', name: 'amount1Min', type: 'uint256' }, + { internalType: 'address', name: 'recipient', type: 'address' }, + { internalType: 'uint256', name: 'deadline', type: 'uint256' }, + ], + internalType: 'struct INonfungiblePositionManager.MintParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'mint', + outputs: [ + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + { internalType: 'uint128', name: 'liquidity', type: 'uint128' }, + { internalType: 'uint256', name: 'amount0', type: 'uint256' }, + { internalType: 'uint256', name: 'amount1', type: 'uint256' }, + ], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [{ internalType: 'address', name: 'owner', type: 'address' }], + name: 'balanceOf', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { internalType: 'address', name: 'owner', type: 'address' }, + { internalType: 'uint256', name: 'index', type: 'uint256' }, + ], + name: 'tokenOfOwnerByIndex', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ internalType: 'uint256', name: 'tokenId', type: 'uint256' }], + name: 'positions', + outputs: [ + { internalType: 'uint96', name: 'nonce', type: 'uint96' }, + { internalType: 'address', name: 'operator', type: 'address' }, + { internalType: 'address', name: 'token0', type: 'address' }, + { internalType: 'address', name: 'token1', type: 'address' }, + { internalType: 'uint24', name: 'fee', type: 'uint24' }, + { internalType: 'int24', name: 'tickLower', type: 'int24' }, + { internalType: 'int24', name: 'tickUpper', type: 'int24' }, + { internalType: 'uint128', name: 'liquidity', type: 'uint128' }, + { internalType: 'uint256', name: 'feeGrowthInside0LastX128', type: 'uint256' }, + { internalType: 'uint256', name: 'feeGrowthInside1LastX128', type: 'uint256' }, + { internalType: 'uint128', name: 'tokensOwed0', type: 'uint128' }, + { internalType: 'uint128', name: 'tokensOwed1', type: 'uint128' }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + components: [ + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + { internalType: 'uint128', name: 'liquidity', type: 'uint128' }, + { internalType: 'uint256', name: 'amount0Min', type: 'uint256' }, + { internalType: 'uint256', name: 'amount1Min', type: 'uint256' }, + { internalType: 'uint256', name: 'deadline', type: 'uint256' }, + ], + internalType: 'struct INonfungiblePositionManager.DecreaseLiquidityParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'decreaseLiquidity', + outputs: [ + { internalType: 'uint256', name: 'amount0', type: 'uint256' }, + { internalType: 'uint256', name: 'amount1', type: 'uint256' }, + ], + stateMutability: 'payable', + type: 'function', + }, + { + inputs: [ + { + components: [ + { internalType: 'uint256', name: 'tokenId', type: 'uint256' }, + { internalType: 'address', name: 'recipient', type: 'address' }, + { internalType: 'uint128', name: 'amount0Max', type: 'uint128' }, + { internalType: 'uint128', name: 'amount1Max', type: 'uint128' }, + ], + internalType: 'struct INonfungiblePositionManager.CollectParams', + name: 'params', + type: 'tuple', + }, + ], + name: 'collect', + outputs: [ + { internalType: 'uint256', name: 'amount0', type: 'uint256' }, + { internalType: 'uint256', name: 'amount1', type: 'uint256' }, + ], + stateMutability: 'payable', + type: 'function', + }, +] as const; + +export function tickToSqrtPrice(tick: number): number { + return Math.sqrt(1.0001 ** tick); +} + +export function nearestUsableTick(tick: number, spacing: number): number { + const rounded = Math.round(tick / spacing) * spacing; + return rounded; +} diff --git a/packages/liquidity-widget/src/liquidity/pool-service.ts b/packages/liquidity-widget/src/liquidity/pool-service.ts new file mode 100644 index 0000000..5eecb10 --- /dev/null +++ b/packages/liquidity-widget/src/liquidity/pool-service.ts @@ -0,0 +1,167 @@ +import type { PublicClient } from 'viem'; +import { formatEther } from 'viem'; +import { + POOL_ADDRESS, POOL_ABI, POSITION_MANAGER, POSITION_MANAGER_ABI, + GD_TOKEN, USDGLO_TOKEN, POOL_FEE, IS_GD_TOKEN0, TOKEN0, TOKEN1, + tickToSqrtPrice, +} from './constants'; +import type { PoolData, PositionData } from './types'; + +export async function loadPoolData(publicClient: PublicClient): Promise { + const slot0 = await publicClient.readContract({ + address: POOL_ADDRESS, + abi: POOL_ABI, + functionName: 'slot0', + }); + + const sqrtPriceX96 = slot0[0]; + const currentTick = slot0[1]; + const sqrtPrice = Number(sqrtPriceX96) / (2 ** 96); + const rawPrice = sqrtPrice * sqrtPrice; + const gdPriceInUsdglo = rawPrice === 0 ? 0 : (IS_GD_TOKEN0 ? rawPrice : 1 / rawPrice); + + return { sqrtPriceX96, currentTick, price: rawPrice, gdPriceInUsdglo }; +} + +export async function loadUserBalancesAndAllowances( + publicClient: PublicClient, + userAddress: `0x${string}`, +) { + const [gdBalance, usdgloBalance, gdAllowance, usdgloAllowance] = await Promise.all([ + publicClient.readContract({ + address: GD_TOKEN, abi: (await import('./constants')).ERC20_ABI, + functionName: 'balanceOf', args: [userAddress], + }), + publicClient.readContract({ + address: USDGLO_TOKEN, abi: (await import('./constants')).ERC20_ABI, + functionName: 'balanceOf', args: [userAddress], + }), + publicClient.readContract({ + address: GD_TOKEN, abi: (await import('./constants')).ERC20_ABI, + functionName: 'allowance', args: [userAddress, POSITION_MANAGER], + }), + publicClient.readContract({ + address: USDGLO_TOKEN, abi: (await import('./constants')).ERC20_ABI, + functionName: 'allowance', args: [userAddress, POSITION_MANAGER], + }), + ]); + + return { gdBalance, usdgloBalance, gdAllowance, usdgloAllowance }; +} + +export async function getUserPositions( + publicClient: PublicClient, + userAddress: `0x${string}`, + currentTick: number, +): Promise { + let positionCount: bigint; + try { + positionCount = await publicClient.readContract({ + address: POSITION_MANAGER, + abi: POSITION_MANAGER_ABI, + functionName: 'balanceOf', + args: [userAddress], + }) as bigint; + } catch { + return []; + } + + if (positionCount === 0n) return []; + + const tokenIds: bigint[] = []; + for (let i = 0n; i < positionCount; i++) { + const tokenId = await publicClient.readContract({ + address: POSITION_MANAGER, + abi: POSITION_MANAGER_ABI, + functionName: 'tokenOfOwnerByIndex', + args: [userAddress, i], + }) as bigint; + tokenIds.push(tokenId); + } + + const positions: PositionData[] = []; + for (const tokenId of tokenIds) { + const pos = await publicClient.readContract({ + address: POSITION_MANAGER, + abi: POSITION_MANAGER_ABI, + functionName: 'positions', + args: [tokenId], + }) as readonly [bigint, string, string, string, number, number, number, bigint, bigint, bigint, bigint, bigint]; + + const [, , token0, token1, fee, tickLower, tickUpper, liquidity, , , tokensOwed0, tokensOwed1] = pos; + + const matchesPool = + token0.toLowerCase() === TOKEN0.toLowerCase() && + token1.toLowerCase() === TOKEN1.toLowerCase() && + fee === POOL_FEE; + + if (!matchesPool) continue; + if (liquidity === 0n && tokensOwed0 === 0n && tokensOwed1 === 0n) continue; + + const { amount0, amount1 } = calculatePositionAmounts( + liquidity, tickLower, tickUpper, currentTick, + ); + + positions.push({ + tokenId, + token0, token1, + fee, tickLower, tickUpper, + liquidity, + tokensOwed0, tokensOwed1, + amount0, amount1, + inRange: currentTick >= tickLower && currentTick < tickUpper, + }); + } + + return positions; +} + +function calculatePositionAmounts( + liquidity: bigint, + tickLower: number, + tickUpper: number, + currentTick: number, +): { amount0: bigint; amount1: bigint } { + if (liquidity === 0n) return { amount0: 0n, amount1: 0n }; + + const sqrtPriceLower = tickToSqrtPrice(tickLower); + const sqrtPriceUpper = tickToSqrtPrice(tickUpper); + const sqrtPriceCurrent = tickToSqrtPrice(currentTick); + + const L = Number(liquidity); + let amount0 = 0; + let amount1 = 0; + + if (currentTick < tickLower) { + amount0 = L * (sqrtPriceUpper - sqrtPriceLower) / (sqrtPriceLower * sqrtPriceUpper); + } else if (currentTick >= tickUpper) { + amount1 = L * (sqrtPriceUpper - sqrtPriceLower); + } else { + amount0 = L * (sqrtPriceUpper - sqrtPriceCurrent) / (sqrtPriceCurrent * sqrtPriceUpper); + amount1 = L * (sqrtPriceCurrent - sqrtPriceLower); + } + + return { + amount0: BigInt(Math.floor(amount0)), + amount1: BigInt(Math.floor(amount1)), + }; +} + +export function formatBigIntDisplay(num: bigint): string { + return Intl.NumberFormat().format(Number(formatEther(num))); +} + +export function formatAmount(num: number): string { + if (num === 0) return '0'; + return num.toFixed(6).replace(/\.?0+$/, ''); +} + +export function getGdAndUsdgloAmounts( + amount0: bigint, + amount1: bigint, +): { gdAmount: bigint; usdgloAmount: bigint } { + return { + gdAmount: IS_GD_TOKEN0 ? amount0 : amount1, + usdgloAmount: IS_GD_TOKEN0 ? amount1 : amount0, + }; +} diff --git a/packages/liquidity-widget/src/liquidity/tx-service.ts b/packages/liquidity-widget/src/liquidity/tx-service.ts new file mode 100644 index 0000000..9bf7df2 --- /dev/null +++ b/packages/liquidity-widget/src/liquidity/tx-service.ts @@ -0,0 +1,166 @@ +import type { PublicClient, WalletClient } from 'viem'; +import { + POSITION_MANAGER, POSITION_MANAGER_ABI, ERC20_ABI, + TOKEN0, TOKEN1, POOL_FEE, IS_GD_TOKEN0, +} from './constants'; + +const MAX_UINT128 = 2n ** 128n - 1n; + +export interface TxCallbacks { + onHash?: (hash: `0x${string}`) => void; + onConfirmed?: (hash: `0x${string}`) => void; +} + +export async function approveToken( + publicClient: PublicClient, + walletClient: WalletClient, + account: `0x${string}`, + tokenAddress: `0x${string}`, + amount: bigint, + bufferPercent: number = 5, + callbacks?: TxCallbacks, +): Promise<`0x${string}`> { + const bufferedAmount = amount + (amount * BigInt(bufferPercent) / 100n); + + const { request } = await publicClient.simulateContract({ + account, + address: tokenAddress, + abi: ERC20_ABI, + functionName: 'approve', + args: [POSITION_MANAGER, bufferedAmount], + }); + + const hash = await walletClient.writeContract(request); + callbacks?.onHash?.(hash); + + await publicClient.waitForTransactionReceipt({ hash }); + callbacks?.onConfirmed?.(hash); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + return hash; +} + +export async function addLiquidity( + publicClient: PublicClient, + walletClient: WalletClient, + account: `0x${string}`, + gdAmountWei: bigint, + usdgloAmountWei: bigint, + tickLower: number, + tickUpper: number, + callbacks?: TxCallbacks, +): Promise<`0x${string}`> { + const amount0Desired = IS_GD_TOKEN0 ? gdAmountWei : usdgloAmountWei; + const amount1Desired = IS_GD_TOKEN0 ? usdgloAmountWei : gdAmountWei; + const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); + + const { request } = await publicClient.simulateContract({ + account, + address: POSITION_MANAGER, + abi: POSITION_MANAGER_ABI, + functionName: 'mint', + args: [{ + token0: TOKEN0, + token1: TOKEN1, + fee: POOL_FEE, + tickLower, + tickUpper, + amount0Desired, + amount1Desired, + amount0Min: 0n, + amount1Min: 0n, + recipient: account, + deadline, + }], + }); + + const hash = await walletClient.writeContract(request); + callbacks?.onHash?.(hash); + + await publicClient.waitForTransactionReceipt({ hash }); + callbacks?.onConfirmed?.(hash); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + return hash; +} + +export async function removeLiquidity( + publicClient: PublicClient, + walletClient: WalletClient, + account: `0x${string}`, + tokenId: bigint, + liquidity: bigint, + callbacks?: TxCallbacks, +): Promise<`0x${string}`> { + const deadline = BigInt(Math.floor(Date.now() / 1000) + 1200); + + const { request: decreaseReq } = await publicClient.simulateContract({ + account, + address: POSITION_MANAGER, + abi: POSITION_MANAGER_ABI, + functionName: 'decreaseLiquidity', + args: [{ + tokenId, + liquidity: BigInt(liquidity) as unknown as bigint & { __brand: 'uint128' }, + amount0Min: 0n, + amount1Min: 0n, + deadline, + }], + }); + + const hash = await walletClient.writeContract(decreaseReq); + callbacks?.onHash?.(hash); + await publicClient.waitForTransactionReceipt({ hash }); + callbacks?.onConfirmed?.(hash); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + return hash; +} + +export async function collectFees( + publicClient: PublicClient, + walletClient: WalletClient, + account: `0x${string}`, + tokenId: bigint, + callbacks?: TxCallbacks, +): Promise<`0x${string}`> { + const { request } = await publicClient.simulateContract({ + account, + address: POSITION_MANAGER, + abi: POSITION_MANAGER_ABI, + functionName: 'collect', + args: [{ + tokenId, + recipient: account, + amount0Max: MAX_UINT128, + amount1Max: MAX_UINT128, + }], + }); + + const hash = await walletClient.writeContract(request); + callbacks?.onHash?.(hash); + + await publicClient.waitForTransactionReceipt({ hash }); + callbacks?.onConfirmed?.(hash); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + return hash; +} + +export function parseTxError(error: any): string { + const msg = error?.message || error?.toString() || ''; + + if (msg.includes('User rejected') || msg.includes('user rejected') || msg.includes('ACTION_REJECTED')) + return 'Transaction was rejected in your wallet.'; + if (msg.includes('insufficient funds') || msg.includes('InsufficientFunds')) + return 'Insufficient funds to cover gas fees.'; + if (msg.includes('exceeds balance')) + return 'Token balance too low for this transaction.'; + if (msg.includes('UNPREDICTABLE_GAS') || msg.includes('execution reverted')) + return 'Transaction would fail on-chain. Try adjusting your amounts.'; + if (msg.includes('nonce')) + return 'Transaction nonce conflict. Please reset your wallet or wait for pending transactions.'; + + if (msg.length > 120) return 'Transaction failed. Please try again.'; + return msg || 'Transaction failed. Please try again.'; +} diff --git a/packages/liquidity-widget/src/liquidity/types.ts b/packages/liquidity-widget/src/liquidity/types.ts new file mode 100644 index 0000000..4d14de7 --- /dev/null +++ b/packages/liquidity-widget/src/liquidity/types.ts @@ -0,0 +1,52 @@ +export type StepStatus = 'pending' | 'active' | 'completed' | 'skipped'; + +export interface TxStepInfo { + label: string; + status: StepStatus; + txHash?: string; +} + +export type TxFlowPhase = + | 'idle' + | 'approving-gd' + | 'approving-usdglo' + | 'minting' + | 'success' + | 'error'; + +export interface RangePreset { + id: 'full' | 'wide' | 'narrow'; + label: string; + description: string; + tooltip: string; + getTickRange: (currentTick: number, tickSpacing: number) => { tickLower: number; tickUpper: number }; + concentrationMultiplier: number; +} + +export interface PositionData { + tokenId: bigint; + token0: string; + token1: string; + fee: number; + tickLower: number; + tickUpper: number; + liquidity: bigint; + tokensOwed0: bigint; + tokensOwed1: bigint; + amount0: bigint; + amount1: bigint; + inRange: boolean; +} + +export interface WidgetTheme { + primaryColor: string; + borderRadius: string; + fontFamily: string; +} + +export interface PoolData { + sqrtPriceX96: bigint; + currentTick: number; + price: number; + gdPriceInUsdglo: number; +} diff --git a/packages/liquidity-widget/tsconfig.json b/packages/liquidity-widget/tsconfig.json new file mode 100644 index 0000000..e9d7076 --- /dev/null +++ b/packages/liquidity-widget/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@repo/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/liquidity-widget/tsup.config.liquidity.ts b/packages/liquidity-widget/tsup.config.liquidity.ts new file mode 100644 index 0000000..b2e51b3 --- /dev/null +++ b/packages/liquidity-widget/tsup.config.liquidity.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "tsup" + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm", "iife"], + platform: "browser", + globalName: "GooddollarLiquidityWidget", + splitting: false, + sourcemap: false, + clean: true, + dts: false, + minify: true, + target: "ESNext", + outDir: "dist", +})