diff --git a/.changeset/lp-sdk-gas-estimation.md b/.changeset/lp-sdk-gas-estimation.md new file mode 100644 index 000000000..a66efeda4 --- /dev/null +++ b/.changeset/lp-sdk-gas-estimation.md @@ -0,0 +1,5 @@ +--- +'@uniswap/lp-sdk': minor +--- + +Introduce @uniswap/lp-sdk with LP gas-estimation helpers: pickPreEstimateIndependentAmount for choosing a representative simulation amount from wallet balances, and getV2/getV3/getV4AddLiquidityGasEstimateTransactions for building the ordered approval + create/increase transaction lists to run eth_estimateGas over. diff --git a/bun.lock b/bun.lock index c3e1d6089..a92111893 100644 --- a/bun.lock +++ b/bun.lock @@ -42,7 +42,7 @@ }, "sdks/liquidity-launcher-sdk": { "name": "@uniswap/liquidity-launcher-sdk", - "version": "0.0.0", + "version": "0.4.0", "dependencies": { "@uniswap/sdk-core": "workspace:*", "@uniswap/v3-sdk": "workspace:*", @@ -62,6 +62,30 @@ "typescript": "npm:typescript@^5.6.2", }, }, + "sdks/lp-sdk": { + "name": "@uniswap/lp-sdk", + "version": "0.0.0", + "dependencies": { + "@ethersproject/abi": "^5.5.0", + "@uniswap/permit2-sdk": "workspace:*", + "@uniswap/sdk-core": "workspace:*", + "@uniswap/v2-sdk": "workspace:*", + "@uniswap/v3-sdk": "workspace:*", + "@uniswap/v4-sdk": "workspace:*", + "jsbi": "^3.1.4", + "tiny-invariant": "^1.1.0", + "tslib": "^2.3.0", + }, + "devDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-config-react-app": "7.0.1", + "eslint-plugin-prettier": "^3.4.1", + "prettier": "^2.4.1", + "typescript": "^4.3.3", + }, + }, "sdks/permit2-sdk": { "name": "@uniswap/permit2-sdk", "version": "1.4.0", @@ -82,7 +106,7 @@ }, "sdks/router-sdk": { "name": "@uniswap/router-sdk", - "version": "2.10.5", + "version": "2.11.0", "dependencies": { "@ethersproject/abi": "^5.5.0", "@ethersproject/solidity": "^5.0.9", @@ -138,7 +162,7 @@ }, "sdks/smart-wallet-sdk": { "name": "@uniswap/smart-wallet-sdk", - "version": "2.6.6", + "version": "2.8.0", "dependencies": { "@uniswap/sdk-core": "workspace:~", "viem": "^2.23.5", @@ -235,7 +259,7 @@ }, "sdks/universal-router-sdk": { "name": "@uniswap/universal-router-sdk", - "version": "5.8.0", + "version": "5.9.0", "dependencies": { "@ethersproject/abi": "^5.5.0", "@ethersproject/abstract-signer": "^5.7.0", @@ -272,7 +296,7 @@ }, "sdks/v2-sdk": { "name": "@uniswap/v2-sdk", - "version": "4.20.5", + "version": "4.21.0", "dependencies": { "@ethersproject/address": "^5.0.2", "@ethersproject/bignumber": "^5.5.0", @@ -300,7 +324,7 @@ }, "sdks/v3-sdk": { "name": "@uniswap/v3-sdk", - "version": "3.30.5", + "version": "3.31.0", "dependencies": { "@ethersproject/abi": "^5.5.0", "@ethersproject/abstract-signer": "^5.7.0", @@ -332,7 +356,7 @@ }, "sdks/v4-sdk": { "name": "@uniswap/v4-sdk", - "version": "2.2.3", + "version": "2.3.0", "dependencies": { "@ethersproject/abi": "^5.5.0", "@ethersproject/abstract-signer": "^5.7.0", @@ -932,6 +956,8 @@ "@uniswap/liquidity-launcher-sdk": ["@uniswap/liquidity-launcher-sdk@workspace:sdks/liquidity-launcher-sdk"], + "@uniswap/lp-sdk": ["@uniswap/lp-sdk@workspace:sdks/lp-sdk"], + "@uniswap/permit2-sdk": ["@uniswap/permit2-sdk@workspace:sdks/permit2-sdk"], "@uniswap/router-sdk": ["@uniswap/router-sdk@workspace:sdks/router-sdk"], @@ -2516,6 +2542,10 @@ "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "@uniswap/lp-sdk/@typescript-eslint/parser": ["@typescript-eslint/parser@5.62.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", "@typescript-eslint/typescript-estree": "5.62.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA=="], + + "@uniswap/lp-sdk/typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="], + "@uniswap/permit2-sdk/typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="], "@uniswap/router-sdk/@typescript-eslint/parser": ["@typescript-eslint/parser@5.62.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", "@typescript-eslint/typescript-estree": "5.62.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA=="], @@ -2824,6 +2854,12 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + "@uniswap/lp-sdk/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@5.62.0", "", { "dependencies": { "@typescript-eslint/types": "5.62.0", "@typescript-eslint/visitor-keys": "5.62.0" } }, "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w=="], + + "@uniswap/lp-sdk/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@5.62.0", "", {}, "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ=="], + + "@uniswap/lp-sdk/@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@5.62.0", "", { "dependencies": { "@typescript-eslint/types": "5.62.0", "@typescript-eslint/visitor-keys": "5.62.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", "semver": "^7.3.7", "tsutils": "^3.21.0" } }, "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA=="], + "@uniswap/router-sdk/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@5.62.0", "", { "dependencies": { "@typescript-eslint/types": "5.62.0", "@typescript-eslint/visitor-keys": "5.62.0" } }, "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w=="], "@uniswap/router-sdk/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@5.62.0", "", {}, "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ=="], @@ -2960,6 +2996,12 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@uniswap/lp-sdk/@typescript-eslint/parser/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@5.62.0", "", { "dependencies": { "@typescript-eslint/types": "5.62.0", "eslint-visitor-keys": "^3.3.0" } }, "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw=="], + + "@uniswap/lp-sdk/@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@5.62.0", "", { "dependencies": { "@typescript-eslint/types": "5.62.0", "eslint-visitor-keys": "^3.3.0" } }, "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw=="], + + "@uniswap/lp-sdk/@typescript-eslint/parser/@typescript-eslint/typescript-estree/globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + "@uniswap/router-sdk/@typescript-eslint/parser/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@5.62.0", "", { "dependencies": { "@typescript-eslint/types": "5.62.0", "eslint-visitor-keys": "^3.3.0" } }, "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw=="], "@uniswap/router-sdk/@typescript-eslint/parser/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@5.62.0", "", { "dependencies": { "@typescript-eslint/types": "5.62.0", "eslint-visitor-keys": "^3.3.0" } }, "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw=="], @@ -3050,6 +3092,10 @@ "wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "@uniswap/lp-sdk/@typescript-eslint/parser/@typescript-eslint/typescript-estree/globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "@uniswap/lp-sdk/@typescript-eslint/parser/@typescript-eslint/typescript-estree/globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@uniswap/router-sdk/@typescript-eslint/parser/@typescript-eslint/typescript-estree/globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "@uniswap/router-sdk/@typescript-eslint/parser/@typescript-eslint/typescript-estree/globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], diff --git a/sdks/lp-sdk/.eslintrc.js b/sdks/lp-sdk/.eslintrc.js new file mode 100644 index 000000000..b50d3a462 --- /dev/null +++ b/sdks/lp-sdk/.eslintrc.js @@ -0,0 +1,19 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module' + }, + extends: [ + 'react-app', + 'prettier', + 'plugin:prettier/recommended' + ], + settings: { + react: { + version: '999.999.999' + } + } +}; + diff --git a/sdks/lp-sdk/.gitignore b/sdks/lp-sdk/.gitignore new file mode 100644 index 000000000..82031b5a7 --- /dev/null +++ b/sdks/lp-sdk/.gitignore @@ -0,0 +1,5 @@ +# build +dist + +# dependencies +node_modules \ No newline at end of file diff --git a/sdks/lp-sdk/LICENSE b/sdks/lp-sdk/LICENSE new file mode 100644 index 000000000..b2517ee60 --- /dev/null +++ b/sdks/lp-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Uniswap Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/sdks/lp-sdk/README.md b/sdks/lp-sdk/README.md new file mode 100644 index 000000000..5ff0db0b3 --- /dev/null +++ b/sdks/lp-sdk/README.md @@ -0,0 +1,29 @@ +# @uniswap/lp-sdk + +An SDK for building liquidity-provision flows on top of Uniswap v2, v3, and v4. It composes the +position calldata builders from `@uniswap/v2-sdk`, `@uniswap/v3-sdk`, and `@uniswap/v4-sdk` into +cross-version helpers, following the same pattern `@uniswap/router-sdk` uses for swaps. + +Like all SDKs in this repository, it is chain-agnostic and makes no network calls: helpers take +onchain state (balances, allowances, pool state) as inputs and return transaction tuples +(`{ to, calldata, value }`). + +## Gas estimation helpers + +Estimating the real cost of an LP flow (instead of reserving a hardcoded native-token buffer) +requires estimating every transaction the user will send — token approvals included — before the +user has entered an amount. These helpers produce that transaction list: + +- `pickPreEstimateIndependentAmount(balance0, balance1)` — picks which pool token (and how much of + it) to drive a representative simulation with, from the wallet's two token balances. Balances are + capped at 10^(decimals + 3) raw units; returns `null` when both are zero. +- `getV2AddLiquidityGasEstimateTransactions(params)` — approvals to the v2 router plus the + `addLiquidity`/`addLiquidityETH` transaction. +- `getV3AddLiquidityGasEstimateTransactions(params)` — approvals to the `NonfungiblePositionManager` + plus the mint/increase transaction (`NonfungiblePositionManager.addCallParameters`). +- `getV4AddLiquidityGasEstimateTransactions(params)` — ERC-20 approvals to Permit2, Permit2 + approvals to the v4 position manager, plus the mint/increase transaction + (`V4PositionManager.addCallParameters`). + +Callers run `eth_estimateGas` over the returned transactions in order (from the wallet's address) +and sum the results. Approvals are included pessimistically when no allowance state is provided. diff --git a/sdks/lp-sdk/package.json b/sdks/lp-sdk/package.json new file mode 100644 index 000000000..1968daa3b --- /dev/null +++ b/sdks/lp-sdk/package.json @@ -0,0 +1,64 @@ +{ + "name": "@uniswap/lp-sdk", + "version": "0.0.0", + "description": "An SDK for building liquidity-provision flows on top of Uniswap v2, v3, and v4", + "repository": "https://github.com/Uniswap/sdks.git", + "keywords": [ + "uniswap", + "ethereum" + ], + "license": "MIT", + "main": "./dist/cjs/src/index.js", + "typings": "./dist/types/src/index.d.ts", + "module": "./dist/esm/src/index.js", + "exports": { + ".": { + "types": "./dist/types/src/index.d.ts", + "import": "./dist/esm/src/index.js", + "require": "./dist/cjs/src/index.js" + } + }, + "sideEffects": false, + "files": [ + "dist" + ], + "engines": { + "node": ">=18" + }, + "scripts": { + "build": "bun run clean && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && tsc -p tsconfig.types.json", + "clean": "rm -rf dist", + "lint": "eslint 'src/**/*.{js,ts,tsx}' --max-warnings 0", + "release": "changeset publish", + "test": "bun test" + }, + "dependencies": { + "@ethersproject/abi": "^5.5.0", + "@uniswap/permit2-sdk": "workspace:*", + "@uniswap/sdk-core": "workspace:*", + "@uniswap/v2-sdk": "workspace:*", + "@uniswap/v3-sdk": "workspace:*", + "@uniswap/v4-sdk": "workspace:*", + "jsbi": "^3.1.4", + "tiny-invariant": "^1.1.0", + "tslib": "^2.3.0" + }, + "devDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-config-react-app": "7.0.1", + "eslint-plugin-prettier": "^3.4.1", + "prettier": "^2.4.1", + "typescript": "^4.3.3" + }, + "prettier": { + "printWidth": 120, + "semi": false, + "singleQuote": true + }, + "publishConfig": { + "access": "public", + "provenance": true + } +} diff --git a/sdks/lp-sdk/src/constants.ts b/sdks/lp-sdk/src/constants.ts new file mode 100644 index 000000000..8109370a2 --- /dev/null +++ b/sdks/lp-sdk/src/constants.ts @@ -0,0 +1,14 @@ +import { Percent } from '@uniswap/sdk-core' + +/** + * Default slippage tolerance applied when none is provided. Matches the default used + * by the Uniswap Labs liquidity service so estimates line up with the transactions + * users actually sign. + */ +export const DEFAULT_LP_SLIPPAGE_TOLERANCE = new Percent(250, 10_000) // 2.5% + +/** + * Default slippage tolerance for v4 pools with a native currency side. Matches the + * Uniswap Labs liquidity service default for native v4 pools. + */ +export const DEFAULT_NATIVE_V4_SLIPPAGE_TOLERANCE = new Percent(5, 10_000) // 0.05% diff --git a/sdks/lp-sdk/src/index.ts b/sdks/lp-sdk/src/index.ts new file mode 100644 index 000000000..c56f491eb --- /dev/null +++ b/sdks/lp-sdk/src/index.ts @@ -0,0 +1,9 @@ +export { DEFAULT_LP_SLIPPAGE_TOLERANCE, DEFAULT_NATIVE_V4_SLIPPAGE_TOLERANCE } from './constants' +export type { LpGasEstimateTransaction, Permit2AllowanceInput, TokenAllowanceInput } from './types' +export { pickPreEstimateIndependentAmount } from './utils/pickPreEstimateIndependentAmount' +export { getV2AddLiquidityGasEstimateTransactions } from './v2' +export type { V2AddLiquidityGasEstimateParams } from './v2' +export { getV3AddLiquidityGasEstimateTransactions } from './v3' +export type { V3AddLiquidityGasEstimateParams } from './v3' +export { getV4AddLiquidityGasEstimateTransactions } from './v4' +export type { V4AddLiquidityGasEstimateParams } from './v4' diff --git a/sdks/lp-sdk/src/types.ts b/sdks/lp-sdk/src/types.ts new file mode 100644 index 000000000..bed4c52ba --- /dev/null +++ b/sdks/lp-sdk/src/types.ts @@ -0,0 +1,48 @@ +import { BigintIsh } from '@uniswap/sdk-core' + +/** + * A transaction to include in a gas-estimation batch. + * + * The SDK never talks to the chain: callers run `eth_estimateGas` over the returned + * list (in order, from the position owner's address) with their own client and sum + * the results to get the full cost of the flow. + */ +export interface LpGasEstimateTransaction { + to: string + calldata: string + value: string +} + +/** + * The caller-provided allowance state for one pool token. All fields are optional: + * when omitted the token is assumed to have zero allowance, which yields the + * worst-case (most transactions) estimate. + */ +export interface TokenAllowanceInput { + /** + * Current ERC-20 allowance granted by the wallet to the relevant spender + * (v2 router / v3 position manager / Permit2 for v4). Assumed 0 when omitted. + */ + allowance?: BigintIsh + /** + * USDT-style tokens revert on approve when the current allowance is non-zero, so + * they need an approve(0) reset transaction first. Only relevant when `allowance` + * is non-zero but insufficient. + */ + requiresReset?: boolean +} + +/** + * Allowance state for one pool token in a v4 flow, which pulls funds through Permit2. + */ +export interface Permit2AllowanceInput extends TokenAllowanceInput { + /** + * Current Permit2 allowance (token -> position manager) for the wallet. Assumed + * absent when omitted. The allowance counts as active when `amount` covers the + * required amount and `expiration` is at or after the flow's `deadline`. + */ + permit2Allowance?: { + amount: BigintIsh + expiration: BigintIsh + } +} diff --git a/sdks/lp-sdk/src/utils/approvals.ts b/sdks/lp-sdk/src/utils/approvals.ts new file mode 100644 index 000000000..74b713f91 --- /dev/null +++ b/sdks/lp-sdk/src/utils/approvals.ts @@ -0,0 +1,89 @@ +import { Interface } from '@ethersproject/abi' +import { MaxAllowanceTransferAmount, permit2Address } from '@uniswap/permit2-sdk' +import { BigintIsh, MaxUint256 } from '@uniswap/sdk-core' +import JSBI from 'jsbi' +import { LpGasEstimateTransaction, Permit2AllowanceInput, TokenAllowanceInput } from '../types' + +const ZERO = JSBI.BigInt(0) +const ZERO_VALUE = '0x00' + +const ERC20_INTERFACE = new Interface(['function approve(address spender, uint256 amount) external returns (bool)']) + +const PERMIT2_INTERFACE = new Interface([ + 'function approve(address token, address spender, uint160 amount, uint48 expiration) external', +]) + +function toJSBI(value: BigintIsh): JSBI { + return JSBI.BigInt(value.toString()) +} + +function encodeErc20Approve(token: string, spender: string, amount: string): LpGasEstimateTransaction { + return { + to: token, + calldata: ERC20_INTERFACE.encodeFunctionData('approve', [spender, amount]), + value: ZERO_VALUE, + } +} + +/** + * Returns the ERC-20 approval transactions required before `spender` can pull + * `requiredAmount` of `token` from the wallet: an optional approve(0) reset for + * USDT-style tokens followed by an approve(MaxUint256), or nothing when the current + * allowance already covers the required amount. Approves the maximum to mirror the + * transactions the Uniswap Labs liquidity service produces. + */ +export function erc20ApprovalTransactions( + token: string, + requiredAmount: JSBI, + spender: string, + input?: TokenAllowanceInput +): LpGasEstimateTransaction[] { + const allowance = input?.allowance !== undefined ? toJSBI(input.allowance) : ZERO + if (JSBI.greaterThanOrEqual(allowance, requiredAmount)) { + return [] + } + + const transactions: LpGasEstimateTransaction[] = [] + if (input?.requiresReset && JSBI.greaterThan(allowance, ZERO)) { + transactions.push(encodeErc20Approve(token, spender, '0')) + } + transactions.push(encodeErc20Approve(token, spender, MaxUint256.toString())) + return transactions +} + +/** + * Returns the Permit2 approval transaction (Permit2.approve, granting `spender` an + * allowance of `token`) required for a v4 flow, or none when the caller-provided + * Permit2 allowance covers `requiredAmount` and does not expire before `deadline`. + * The new approval uses Permit2's maximum amount and `deadline` as its expiration. + */ +export function permit2ApprovalTransactions( + token: string, + requiredAmount: JSBI, + spender: string, + chainId: number, + deadline: BigintIsh, + input?: Permit2AllowanceInput +): LpGasEstimateTransaction[] { + const existing = input?.permit2Allowance + if ( + existing && + JSBI.greaterThanOrEqual(toJSBI(existing.amount), requiredAmount) && + JSBI.greaterThanOrEqual(toJSBI(existing.expiration), toJSBI(deadline)) + ) { + return [] + } + + return [ + { + to: permit2Address(chainId), + calldata: PERMIT2_INTERFACE.encodeFunctionData('approve', [ + token, + spender, + MaxAllowanceTransferAmount.toString(), + deadline.toString(), + ]), + value: ZERO_VALUE, + }, + ] +} diff --git a/sdks/lp-sdk/src/utils/pickPreEstimateIndependentAmount.test.ts b/sdks/lp-sdk/src/utils/pickPreEstimateIndependentAmount.test.ts new file mode 100644 index 000000000..7827d8ae6 --- /dev/null +++ b/sdks/lp-sdk/src/utils/pickPreEstimateIndependentAmount.test.ts @@ -0,0 +1,61 @@ +import { CurrencyAmount, Ether, Token } from '@uniswap/sdk-core' +import { pickPreEstimateIndependentAmount } from './pickPreEstimateIndependentAmount' + +describe('pickPreEstimateIndependentAmount', () => { + const USDC = new Token(1, '0x0000000000000000000000000000000000000001', 6, 'USDC') + const WETH = new Token(1, '0x0000000000000000000000000000000000000002', 18, 'WETH') + const ETH = Ether.onChain(1) + + it('returns null when both balances are zero', () => { + const result = pickPreEstimateIndependentAmount( + CurrencyAmount.fromRawAmount(USDC, 0), + CurrencyAmount.fromRawAmount(WETH, 0) + ) + expect(result).toBeNull() + }) + + it('returns the non-zero side when the other balance is zero', () => { + const wethBalance = CurrencyAmount.fromRawAmount(WETH, (5n * 10n ** 18n).toString()) // 5 WETH + const result = pickPreEstimateIndependentAmount(CurrencyAmount.fromRawAmount(USDC, 0), wethBalance) + expect(result?.currency).toBe(WETH) + expect(result?.quotient.toString()).toEqual((5n * 10n ** 18n).toString()) + }) + + it('picks the side with the larger capped balance when both are below the cap', () => { + // 100 USDC = 1e8 raw; 0.0001 WETH = 1e14 raw. WETH wins on raw units. + const result = pickPreEstimateIndependentAmount( + CurrencyAmount.fromRawAmount(USDC, (100n * 10n ** 6n).toString()), + CurrencyAmount.fromRawAmount(WETH, (10n ** 14n).toString()) + ) + expect(result?.currency).toBe(WETH) + expect(result?.quotient.toString()).toEqual((10n ** 14n).toString()) + }) + + it('caps the returned amount at 10^(decimals + 3)', () => { + // Whale: 1,000,000 WETH; cap for an 18-decimal token is 10^21 = 1000 WETH. + const result = pickPreEstimateIndependentAmount( + CurrencyAmount.fromRawAmount(USDC, 0), + CurrencyAmount.fromRawAmount(WETH, (1_000_000n * 10n ** 18n).toString()) + ) + expect(result?.quotient.toString()).toEqual((10n ** 21n).toString()) + }) + + it('prefers the first side when both sides cap equally', () => { + const USDT = new Token(1, '0x0000000000000000000000000000000000000003', 6, 'USDT') + const result = pickPreEstimateIndependentAmount( + CurrencyAmount.fromRawAmount(USDC, (10n ** 9n).toString()), + CurrencyAmount.fromRawAmount(USDT, (10n ** 9n).toString()) + ) + expect(result?.currency).toBe(USDC) + expect(result?.quotient.toString()).toEqual((10n ** 9n).toString()) + }) + + it('supports native currency balances', () => { + const result = pickPreEstimateIndependentAmount( + CurrencyAmount.fromRawAmount(ETH, (3n * 10n ** 18n).toString()), + CurrencyAmount.fromRawAmount(USDC, (100n * 10n ** 6n).toString()) + ) + expect(result?.currency).toBe(ETH) + expect(result?.quotient.toString()).toEqual((3n * 10n ** 18n).toString()) + }) +}) diff --git a/sdks/lp-sdk/src/utils/pickPreEstimateIndependentAmount.ts b/sdks/lp-sdk/src/utils/pickPreEstimateIndependentAmount.ts new file mode 100644 index 000000000..9f132fcd3 --- /dev/null +++ b/sdks/lp-sdk/src/utils/pickPreEstimateIndependentAmount.ts @@ -0,0 +1,51 @@ +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' +import JSBI from 'jsbi' + +const ZERO = JSBI.BigInt(0) +const TEN = JSBI.BigInt(10) + +// balances are capped at 10^(decimals + 3) raw units, i.e. 1000 whole tokens +const CAP_DECIMALS_OFFSET = 3 + +function capBalance(balance: CurrencyAmount): JSBI { + const cap = JSBI.exponentiate(TEN, JSBI.BigInt(balance.currency.decimals + CAP_DECIMALS_OFFSET)) + return JSBI.lessThan(balance.quotient, cap) ? balance.quotient : cap +} + +/** + * Picks the side (balance0 or balance1) with the larger capped raw-unit balance to use + * as the independent amount when simulating an LP transaction for gas estimation. + * + * Each balance is capped at 10^(decimals + 3) raw units to keep simulated amounts + * realistic regardless of whale balances, while still ensuring a valid transaction. + * + * Note: capped balances are compared as raw integers, not by USD/value. Tokens with + * more decimals (e.g. 18-decimal WETH) will therefore tend to win over lower-decimal + * tokens (e.g. 6-decimal USDC) even when the lower-decimal side is worth more. This is + * intentional — the caller only needs *any* non-zero, representative amount for gas + * simulation, and the chosen side is always a token the user actually holds. The cap + * keeps both sides within ~three orders of magnitude of each other, bounding the bias. + * Ties go to balance0. + * + * Returns null when both balances are zero — there is no valid simulation input in + * that case, and the user cannot perform the LP action anyway. + * + * @param balance0 the wallet's balance of one pool currency + * @param balance1 the wallet's balance of the other pool currency + * @returns the capped balance to use as the independent amount, or null + */ +export function pickPreEstimateIndependentAmount( + balance0: CurrencyAmount, + balance1: CurrencyAmount +): CurrencyAmount | null { + const capped0 = capBalance(balance0) + const capped1 = capBalance(balance1) + + if (JSBI.equal(capped0, ZERO) && JSBI.equal(capped1, ZERO)) { + return null + } + + return JSBI.greaterThanOrEqual(capped0, capped1) + ? CurrencyAmount.fromRawAmount(balance0.currency, capped0) + : CurrencyAmount.fromRawAmount(balance1.currency, capped1) +} diff --git a/sdks/lp-sdk/src/v2.test.ts b/sdks/lp-sdk/src/v2.test.ts new file mode 100644 index 000000000..82070f158 --- /dev/null +++ b/sdks/lp-sdk/src/v2.test.ts @@ -0,0 +1,114 @@ +import { Interface } from '@ethersproject/abi' +import { CurrencyAmount, Ether, MaxUint256, Token, V2_ROUTER_ADDRESSES, WETH9 } from '@uniswap/sdk-core' +import { Pair } from '@uniswap/v2-sdk' +import { getV2AddLiquidityGasEstimateTransactions } from './v2' + +const ERC20_INTERFACE = new Interface(['function approve(address spender, uint256 amount) external returns (bool)']) +const V2_ROUTER_INTERFACE = new Interface([ + 'function addLiquidity(address tokenA, address tokenB, uint256 amountADesired, uint256 amountBDesired, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) external returns (uint256, uint256, uint256)', + 'function addLiquidityETH(address token, uint256 amountTokenDesired, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline) external payable returns (uint256, uint256, uint256)', +]) + +describe('getV2AddLiquidityGasEstimateTransactions', () => { + const USDC = new Token(1, '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 6, 'USDC') + const DAI = new Token(1, '0x6B175474E89094C44Da98b954EedeAC495271d0F', 18, 'DAI') + // 1 USDC = 2 DAI + const pair = new Pair( + CurrencyAmount.fromRawAmount(USDC, (1_000_000n * 10n ** 6n).toString()), + CurrencyAmount.fromRawAmount(DAI, (2_000_000n * 10n ** 18n).toString()) + ) + + const router = V2_ROUTER_ADDRESSES[1] + const recipient = '0x000000000000000000000000000000000000000f' + const deadline = '1800000000' + const independentAmount = CurrencyAmount.fromRawAmount(USDC, (100n * 10n ** 6n).toString()) + const baseParams = { pair, independentAmount, recipient, deadline } + + it('returns max approvals for both tokens plus the addLiquidity transaction when no allowances are given', () => { + const transactions = getV2AddLiquidityGasEstimateTransactions(baseParams) + + expect(transactions).toHaveLength(3) + expect(transactions[0]).toEqual({ + to: pair.token0.address, + calldata: ERC20_INTERFACE.encodeFunctionData('approve', [router, MaxUint256.toString()]), + value: '0x00', + }) + expect(transactions[1].to).toEqual(pair.token1.address) + + // dependent side quoted from reserves at 1 USDC = 2 DAI, mins at the default 2.5% slippage + expect(transactions[2]).toEqual({ + to: router, + calldata: V2_ROUTER_INTERFACE.encodeFunctionData('addLiquidity', [ + pair.token0.address, + pair.token1.address, + (200n * 10n ** 18n).toString(), // DAI sorts before USDC + (100n * 10n ** 6n).toString(), + (195n * 10n ** 18n).toString(), + (975n * 10n ** 5n).toString(), + recipient, + deadline, + ]), + value: '0x00', + }) + }) + + it('omits approvals whose allowance already covers the required amount', () => { + const transactions = getV2AddLiquidityGasEstimateTransactions({ + ...baseParams, + token0: { allowance: (10n ** 24n).toString() }, + token1: { allowance: (10n ** 24n).toString() }, + }) + + expect(transactions).toHaveLength(1) + expect(transactions[0].to).toEqual(router) + }) + + it('uses addLiquidityETH and attaches value when one side is native', () => { + const eth = Ether.onChain(1) + // 1 ETH = 2000 DAI + const ethPair = new Pair( + CurrencyAmount.fromRawAmount(WETH9[1], (1_000n * 10n ** 18n).toString()), + CurrencyAmount.fromRawAmount(DAI, (2_000_000n * 10n ** 18n).toString()) + ) + + const transactions = getV2AddLiquidityGasEstimateTransactions({ + ...baseParams, + pair: ethPair, + independentAmount: CurrencyAmount.fromRawAmount(eth, (10n ** 18n).toString()), + }) + + // approval for DAI only, then addLiquidityETH carrying the native value + expect(transactions).toHaveLength(2) + expect(transactions[0].to).toEqual(DAI.address) + expect(transactions[1].to).toEqual(router) + expect(BigInt(transactions[1].value)).toEqual(10n ** 18n) + + const decoded = V2_ROUTER_INTERFACE.decodeFunctionData('addLiquidityETH', transactions[1].calldata) + expect(decoded[0]).toEqual(DAI.address) + expect(decoded[1].toString()).toEqual((2_000n * 10n ** 18n).toString()) + expect(decoded[4].toLowerCase()).toEqual(recipient.toLowerCase()) + }) + + it('uses the dependent amount override when provided', () => { + const emptyPair = new Pair(CurrencyAmount.fromRawAmount(USDC, 0), CurrencyAmount.fromRawAmount(DAI, 0)) + const transactions = getV2AddLiquidityGasEstimateTransactions({ + ...baseParams, + pair: emptyPair, + dependentAmount: CurrencyAmount.fromRawAmount(DAI, (300n * 10n ** 18n).toString()), + }) + + const decoded = V2_ROUTER_INTERFACE.decodeFunctionData('addLiquidity', transactions[2].calldata) + expect(decoded[2].toString()).toEqual((300n * 10n ** 18n).toString()) + expect(decoded[3].toString()).toEqual((100n * 10n ** 6n).toString()) + }) + + it('throws when the independent amount is not a pair token', () => { + const OTHER = new Token(1, '0x0000000000000000000000000000000000000009', 18, 'OTHER') + expect(() => + getV2AddLiquidityGasEstimateTransactions({ + ...baseParams, + independentAmount: CurrencyAmount.fromRawAmount(OTHER, 1), + }) + ).toThrow('INDEPENDENT_NOT_IN_PAIR') + }) +}) diff --git a/sdks/lp-sdk/src/v2.ts b/sdks/lp-sdk/src/v2.ts new file mode 100644 index 000000000..c1a88bde1 --- /dev/null +++ b/sdks/lp-sdk/src/v2.ts @@ -0,0 +1,149 @@ +import { Interface } from '@ethersproject/abi' +import { + BigintIsh, + Currency, + CurrencyAmount, + Fraction, + NativeCurrency, + Percent, + Token, + V2_ROUTER_ADDRESSES, +} from '@uniswap/sdk-core' +import { Pair } from '@uniswap/v2-sdk' +import { toHex } from '@uniswap/v3-sdk' +import JSBI from 'jsbi' +import invariant from 'tiny-invariant' +import { DEFAULT_LP_SLIPPAGE_TOLERANCE } from './constants' +import { LpGasEstimateTransaction, TokenAllowanceInput } from './types' +import { erc20ApprovalTransactions } from './utils/approvals' + +const ONE = new Fraction(1, 1) +const ZERO_VALUE = '0x00' + +const V2_ROUTER_INTERFACE = new Interface([ + 'function addLiquidity(address tokenA, address tokenB, uint256 amountADesired, uint256 amountBDesired, uint256 amountAMin, uint256 amountBMin, address to, uint256 deadline) external returns (uint256, uint256, uint256)', + 'function addLiquidityETH(address token, uint256 amountTokenDesired, uint256 amountTokenMin, uint256 amountETHMin, address to, uint256 deadline) external payable returns (uint256, uint256, uint256)', +]) + +function minimumAmount(amount: CurrencyAmount, slippageTolerance: Percent): JSBI { + return slippageTolerance.greaterThan(0) ? amount.multiply(ONE.subtract(slippageTolerance)).quotient : amount.quotient +} + +export interface V2AddLiquidityGasEstimateParams { + /** + * The pair to add liquidity to, constructed with its current reserves. + */ + pair: Pair + /** + * The side of the pair the estimate is driven by, typically the output of + * {@link pickPreEstimateIndependentAmount}. The other side's amount is quoted from + * the pair reserves. May be a native currency amount. + */ + independentAmount: CurrencyAmount + /** + * Overrides the reserve-derived dependent amount. Required when the pair has no + * liquidity to quote against. + */ + dependentAmount?: CurrencyAmount + /** + * The account that receives the LP tokens. + */ + recipient: string + /** + * Spend native currency via addLiquidityETH (one pair token must be its wrapped + * form). Defaults to the independent amount's currency when that is native. + */ + useNative?: NativeCurrency + /** + * Defaults to {@link DEFAULT_LP_SLIPPAGE_TOLERANCE}. + */ + slippageTolerance?: Percent + /** + * When the transaction expires, in epoch seconds. + */ + deadline: BigintIsh + /** + * Allowance state of pair.token0 for the v2 router. Zero allowance is assumed when + * omitted, yielding the worst-case estimate. + */ + token0?: TokenAllowanceInput + /** + * Allowance state of pair.token1 for the v2 router. + */ + token1?: TokenAllowanceInput +} + +/** + * Returns the ordered list of transactions a wallet must send to add liquidity to a + * Uniswap v2 pair, for use with `eth_estimateGas`: any required ERC-20 approvals to + * the v2 router, followed by the addLiquidity/addLiquidityETH transaction itself. + * + * v2 has no position NFT, so the same call covers both creating and increasing a + * position. The calldata mirrors what the Uniswap Labs liquidity service produces for + * the transaction users actually sign. + */ +export function getV2AddLiquidityGasEstimateTransactions( + params: V2AddLiquidityGasEstimateParams +): LpGasEstimateTransaction[] { + const { pair, independentAmount, recipient, deadline } = params + + const routerAddress: string | undefined = V2_ROUTER_ADDRESSES[pair.chainId] + invariant(routerAddress !== undefined, 'NO_ROUTER_ON_CHAIN') + + const useNative = + params.useNative ?? + (independentAmount.currency.isNative ? (independentAmount.currency as NativeCurrency) : undefined) + + const wrappedIndependent = independentAmount.wrapped + const independentIsToken0 = wrappedIndependent.currency.equals(pair.token0) + invariant(independentIsToken0 || wrappedIndependent.currency.equals(pair.token1), 'INDEPENDENT_NOT_IN_PAIR') + + const wrappedDependent = + params.dependentAmount?.wrapped ?? pair.priceOf(wrappedIndependent.currency).quote(wrappedIndependent) + const amount0 = independentIsToken0 ? wrappedIndependent : wrappedDependent + const amount1 = independentIsToken0 ? wrappedDependent : wrappedIndependent + + const slippageTolerance = params.slippageTolerance ?? DEFAULT_LP_SLIPPAGE_TOLERANCE + const amount0Min = minimumAmount(amount0, slippageTolerance) + const amount1Min = minimumAmount(amount1, slippageTolerance) + + let calldata: string + let value: string + if (useNative) { + const nativeIsToken0 = useNative.wrapped.equals(pair.token0) + invariant(nativeIsToken0 || useNative.wrapped.equals(pair.token1), 'NATIVE_NOT_IN_PAIR') + const tokenAmount = nativeIsToken0 ? amount1 : amount0 + const nativeAmount = nativeIsToken0 ? amount0 : amount1 + calldata = V2_ROUTER_INTERFACE.encodeFunctionData('addLiquidityETH', [ + tokenAmount.currency.address, + tokenAmount.quotient.toString(), + (nativeIsToken0 ? amount1Min : amount0Min).toString(), + (nativeIsToken0 ? amount0Min : amount1Min).toString(), + recipient, + deadline.toString(), + ]) + value = toHex(nativeAmount.quotient) + } else { + calldata = V2_ROUTER_INTERFACE.encodeFunctionData('addLiquidity', [ + pair.token0.address, + pair.token1.address, + amount0.quotient.toString(), + amount1.quotient.toString(), + amount0Min.toString(), + amount1Min.toString(), + recipient, + deadline.toString(), + ]) + value = ZERO_VALUE + } + + const transactions: LpGasEstimateTransaction[] = [] + if (!useNative?.wrapped.equals(pair.token0)) { + transactions.push(...erc20ApprovalTransactions(pair.token0.address, amount0.quotient, routerAddress, params.token0)) + } + if (!useNative?.wrapped.equals(pair.token1)) { + transactions.push(...erc20ApprovalTransactions(pair.token1.address, amount1.quotient, routerAddress, params.token1)) + } + transactions.push({ to: routerAddress, calldata, value }) + return transactions +} diff --git a/sdks/lp-sdk/src/v3.test.ts b/sdks/lp-sdk/src/v3.test.ts new file mode 100644 index 000000000..a291e5a0f --- /dev/null +++ b/sdks/lp-sdk/src/v3.test.ts @@ -0,0 +1,141 @@ +import { Interface } from '@ethersproject/abi' +import { + CurrencyAmount, + Ether, + MaxUint256, + NONFUNGIBLE_POSITION_MANAGER_ADDRESSES, + Percent, + Token, + WETH9, +} from '@uniswap/sdk-core' +import { encodeSqrtRatioX96, FeeAmount, NonfungiblePositionManager, Pool, Position } from '@uniswap/v3-sdk' +import { getV3AddLiquidityGasEstimateTransactions } from './v3' + +const ERC20_INTERFACE = new Interface(['function approve(address spender, uint256 amount) external returns (bool)']) + +describe('getV3AddLiquidityGasEstimateTransactions', () => { + const token0 = new Token(1, '0x0000000000000000000000000000000000000001', 18, 't0') + const token1 = new Token(1, '0x0000000000000000000000000000000000000002', 18, 't1') + const pool = new Pool(token0, token1, FeeAmount.MEDIUM, encodeSqrtRatioX96(1, 1), 0, 0, []) + + const positionManager = NONFUNGIBLE_POSITION_MANAGER_ADDRESSES[1] + const recipient = '0x000000000000000000000000000000000000000f' + const deadline = '1800000000' + const independentAmount = CurrencyAmount.fromRawAmount(token0, (10n ** 18n).toString()) + const baseParams = { pool, tickLower: -60, tickUpper: 60, independentAmount, recipient, deadline } + + const expectedPosition = Position.fromAmount0({ + pool, + tickLower: -60, + tickUpper: 60, + amount0: independentAmount.quotient, + useFullPrecision: true, + }) + + it('returns max approvals for both tokens plus the mint transaction when no allowances are given', () => { + const transactions = getV3AddLiquidityGasEstimateTransactions(baseParams) + + expect(transactions).toHaveLength(3) + expect(transactions[0]).toEqual({ + to: token0.address, + calldata: ERC20_INTERFACE.encodeFunctionData('approve', [positionManager, MaxUint256.toString()]), + value: '0x00', + }) + expect(transactions[1]).toEqual({ + to: token1.address, + calldata: ERC20_INTERFACE.encodeFunctionData('approve', [positionManager, MaxUint256.toString()]), + value: '0x00', + }) + + const expected = NonfungiblePositionManager.addCallParameters(expectedPosition, { + recipient, + createPool: undefined, + slippageTolerance: new Percent(250, 10_000), + deadline, + useNative: undefined, + }) + expect(transactions[2]).toEqual({ to: positionManager, calldata: expected.calldata, value: expected.value }) + }) + + it('omits approvals whose allowance already covers the required amount', () => { + const transactions = getV3AddLiquidityGasEstimateTransactions({ + ...baseParams, + token0: { allowance: (10n ** 24n).toString() }, + token1: { allowance: (10n ** 24n).toString() }, + }) + + expect(transactions).toHaveLength(1) + expect(transactions[0].to).toEqual(positionManager) + }) + + it('treats the desired mint amounts as the approval threshold', () => { + // v3 pulls at most amount0Desired/amount1Desired (mintAmounts), so an allowance + // exactly equal to them needs no approval + const desired = expectedPosition.mintAmounts + const transactions = getV3AddLiquidityGasEstimateTransactions({ + ...baseParams, + token0: { allowance: desired.amount0.toString() }, + token1: { allowance: desired.amount1.toString() }, + }) + + expect(transactions).toHaveLength(1) + expect(transactions[0].to).toEqual(positionManager) + }) + + it('prepends an approve(0) reset for tokens that require it', () => { + const transactions = getV3AddLiquidityGasEstimateTransactions({ + ...baseParams, + token0: { allowance: 1, requiresReset: true }, + token1: { allowance: (10n ** 24n).toString() }, + }) + + expect(transactions).toHaveLength(3) + expect(transactions[0]).toEqual({ + to: token0.address, + calldata: ERC20_INTERFACE.encodeFunctionData('approve', [positionManager, '0']), + value: '0x00', + }) + expect(transactions[1].to).toEqual(token0.address) + expect(transactions[2].to).toEqual(positionManager) + }) + + it('skips the native side approval and attaches value when the independent amount is native', () => { + const dai = new Token(1, '0x6B175474E89094C44Da98b954EedeAC495271d0F', 18, 'DAI') + const wethPool = new Pool(dai, WETH9[1], FeeAmount.MEDIUM, encodeSqrtRatioX96(1, 1), 0, 0, []) + const eth = Ether.onChain(1) + + const transactions = getV3AddLiquidityGasEstimateTransactions({ + ...baseParams, + pool: wethPool, + independentAmount: CurrencyAmount.fromRawAmount(eth, (10n ** 18n).toString()), + }) + + // approval for DAI only, then the mint carrying native value + expect(transactions).toHaveLength(2) + expect(transactions[0].to).toEqual(dai.address) + expect(transactions[1].to).toEqual(positionManager) + expect(transactions[1].value).not.toEqual('0x00') + }) + + it('produces an increase transaction when tokenId is set', () => { + const transactions = getV3AddLiquidityGasEstimateTransactions({ + ...baseParams, + recipient: undefined, + tokenId: '42', + }) + + const expected = NonfungiblePositionManager.addCallParameters(expectedPosition, { + tokenId: '42', + slippageTolerance: new Percent(250, 10_000), + deadline, + useNative: undefined, + }) + expect(transactions[2]).toEqual({ to: positionManager, calldata: expected.calldata, value: expected.value }) + }) + + it('throws when minting without a recipient', () => { + expect(() => getV3AddLiquidityGasEstimateTransactions({ ...baseParams, recipient: undefined })).toThrow( + 'NO_RECIPIENT' + ) + }) +}) diff --git a/sdks/lp-sdk/src/v3.ts b/sdks/lp-sdk/src/v3.ts new file mode 100644 index 000000000..ab206a7e9 --- /dev/null +++ b/sdks/lp-sdk/src/v3.ts @@ -0,0 +1,123 @@ +import { + BigintIsh, + Currency, + CurrencyAmount, + NativeCurrency, + NONFUNGIBLE_POSITION_MANAGER_ADDRESSES, + Percent, +} from '@uniswap/sdk-core' +import { AddLiquidityOptions, NonfungiblePositionManager, Pool, Position } from '@uniswap/v3-sdk' +import invariant from 'tiny-invariant' +import { DEFAULT_LP_SLIPPAGE_TOLERANCE } from './constants' +import { LpGasEstimateTransaction, TokenAllowanceInput } from './types' +import { erc20ApprovalTransactions } from './utils/approvals' + +export interface V3AddLiquidityGasEstimateParams { + /** + * The pool to add liquidity to, carrying current price, liquidity, and tick. + */ + pool: Pool + tickLower: number + tickUpper: number + /** + * The side of the pool the estimate is driven by, typically the output of + * {@link pickPreEstimateIndependentAmount}. The other side's amount is derived + * from the pool price and the tick range. May be a native currency amount. + */ + independentAmount: CurrencyAmount + /** + * The account minting the position. Required when creating a new position + * (no `tokenId`). + */ + recipient?: string + /** + * When set, produces an increase of the existing position instead of a mint. + */ + tokenId?: BigintIsh + /** + * Whether to prepend pool creation/initialization to the mint call. + */ + createPool?: boolean + /** + * Spend native currency (one pool token must be its wrapped form). Defaults to the + * independent amount's currency when that is native. + */ + useNative?: NativeCurrency + /** + * Defaults to {@link DEFAULT_LP_SLIPPAGE_TOLERANCE}. + */ + slippageTolerance?: Percent + /** + * When the transaction expires, in epoch seconds. + */ + deadline: BigintIsh + /** + * Allowance state of pool.token0 for the position manager. Zero allowance is + * assumed when omitted, yielding the worst-case estimate. + */ + token0?: TokenAllowanceInput + /** + * Allowance state of pool.token1 for the position manager. + */ + token1?: TokenAllowanceInput +} + +/** + * Returns the ordered list of transactions a wallet must send to create or increase a + * Uniswap v3 position, for use with `eth_estimateGas`: any required ERC-20 approvals + * to the NonfungiblePositionManager, followed by the mint/increase transaction itself. + * + * The position transaction is encoded with `NonfungiblePositionManager.addCallParameters`, + * so the calldata matches what standard SDK consumers (including the Uniswap Labs + * liquidity service) produce for the transaction users actually sign. + */ +export function getV3AddLiquidityGasEstimateTransactions( + params: V3AddLiquidityGasEstimateParams +): LpGasEstimateTransaction[] { + const { pool, tickLower, tickUpper, independentAmount, recipient, tokenId, createPool, deadline } = params + + const positionManagerAddress: string | undefined = NONFUNGIBLE_POSITION_MANAGER_ADDRESSES[pool.chainId] + invariant(positionManagerAddress !== undefined, 'NO_POSITION_MANAGER_ON_CHAIN') + + const useNative = + params.useNative ?? + (independentAmount.currency.isNative ? (independentAmount.currency as NativeCurrency) : undefined) + + const wrappedIndependent = independentAmount.wrapped + let position: Position + if (wrappedIndependent.currency.equals(pool.token0)) { + position = Position.fromAmount0({ + pool, + tickLower, + tickUpper, + amount0: wrappedIndependent.quotient, + useFullPrecision: true, + }) + } else { + invariant(wrappedIndependent.currency.equals(pool.token1), 'INDEPENDENT_NOT_IN_POOL') + position = Position.fromAmount1({ pool, tickLower, tickUpper, amount1: wrappedIndependent.quotient }) + } + + const slippageTolerance = params.slippageTolerance ?? DEFAULT_LP_SLIPPAGE_TOLERANCE + let options: AddLiquidityOptions + if (tokenId !== undefined) { + options = { tokenId, slippageTolerance, deadline, useNative } + } else { + invariant(recipient !== undefined, 'NO_RECIPIENT') + options = { recipient, createPool, slippageTolerance, deadline, useNative } + } + const { calldata, value } = NonfungiblePositionManager.addCallParameters(position, options) + + // v3 pulls at most the desired amounts (mintAmounts); its mintAmountsWithSlippage + // are lower-bound minimums, so mintAmounts is the correct approval threshold + const { amount0, amount1 } = position.mintAmounts + const transactions: LpGasEstimateTransaction[] = [] + if (!useNative?.wrapped.equals(pool.token0)) { + transactions.push(...erc20ApprovalTransactions(pool.token0.address, amount0, positionManagerAddress, params.token0)) + } + if (!useNative?.wrapped.equals(pool.token1)) { + transactions.push(...erc20ApprovalTransactions(pool.token1.address, amount1, positionManagerAddress, params.token1)) + } + transactions.push({ to: positionManagerAddress, calldata, value }) + return transactions +} diff --git a/sdks/lp-sdk/src/v4.test.ts b/sdks/lp-sdk/src/v4.test.ts new file mode 100644 index 000000000..92ac7a945 --- /dev/null +++ b/sdks/lp-sdk/src/v4.test.ts @@ -0,0 +1,193 @@ +import { Interface } from '@ethersproject/abi' +import { MaxAllowanceTransferAmount, PERMIT2_ADDRESS } from '@uniswap/permit2-sdk' +import { CHAIN_TO_ADDRESSES_MAP, CurrencyAmount, Ether, MaxUint256, Percent, Token } from '@uniswap/sdk-core' +import { encodeSqrtRatioX96 } from '@uniswap/v3-sdk' +import { Pool, Position, V4PositionManager } from '@uniswap/v4-sdk' +import { getV4AddLiquidityGasEstimateTransactions } from './v4' + +const ERC20_INTERFACE = new Interface(['function approve(address spender, uint256 amount) external returns (bool)']) +const PERMIT2_INTERFACE = new Interface([ + 'function approve(address token, address spender, uint160 amount, uint48 expiration) external', +]) +const EMPTY_HOOK = '0x0000000000000000000000000000000000000000' + +describe('getV4AddLiquidityGasEstimateTransactions', () => { + const currency0 = new Token(1, '0x0000000000000000000000000000000000000001', 18, 't0') + const currency1 = new Token(1, '0x0000000000000000000000000000000000000002', 18, 't1') + const pool = new Pool(currency0, currency1, 3000, 60, EMPTY_HOOK, encodeSqrtRatioX96(1, 1), 0, 0) + + const positionManager = CHAIN_TO_ADDRESSES_MAP[1].v4PositionManagerAddress as string + const recipient = '0x000000000000000000000000000000000000000f' + const deadline = '1800000000' + const independentAmount = CurrencyAmount.fromRawAmount(currency0, (10n ** 18n).toString()) + const baseParams = { pool, tickLower: -60, tickUpper: 60, independentAmount, recipient, deadline } + + const expectedPosition = Position.fromAmount0({ + pool, + tickLower: -60, + tickUpper: 60, + amount0: independentAmount.quotient, + useFullPrecision: true, + }) + + it('returns ERC-20 and Permit2 approvals for both tokens plus the mint transaction when no allowances are given', () => { + const transactions = getV4AddLiquidityGasEstimateTransactions(baseParams) + + expect(transactions).toHaveLength(5) + expect(transactions[0]).toEqual({ + to: currency0.address, + calldata: ERC20_INTERFACE.encodeFunctionData('approve', [PERMIT2_ADDRESS, MaxUint256.toString()]), + value: '0x00', + }) + expect(transactions[1]).toEqual({ + to: PERMIT2_ADDRESS, + calldata: PERMIT2_INTERFACE.encodeFunctionData('approve', [ + currency0.address, + positionManager, + MaxAllowanceTransferAmount.toString(), + deadline, + ]), + value: '0x00', + }) + expect(transactions[2].to).toEqual(currency1.address) + expect(transactions[3].to).toEqual(PERMIT2_ADDRESS) + + const expected = V4PositionManager.addCallParameters(expectedPosition, { + recipient, + createPool: undefined, + sqrtPriceX96: undefined, + slippageTolerance: new Percent(250, 10_000), + deadline, + useNative: undefined, + }) + expect(transactions[4]).toEqual({ to: positionManager, calldata: expected.calldata, value: expected.value }) + }) + + it('omits approvals covered by existing ERC-20 and Permit2 allowances', () => { + const covered = { + allowance: (10n ** 24n).toString(), + permit2Allowance: { amount: MaxAllowanceTransferAmount.toString(), expiration: deadline }, + } + const transactions = getV4AddLiquidityGasEstimateTransactions({ + ...baseParams, + currency0: covered, + currency1: covered, + }) + + expect(transactions).toHaveLength(1) + expect(transactions[0].to).toEqual(positionManager) + }) + + it('requires allowances to cover the slippage-inflated maximums, not just the desired amounts', () => { + const slippageTolerance = new Percent(250, 10_000) + const desired = expectedPosition.mintAmounts + const maximums = expectedPosition.mintAmountsWithSlippage(slippageTolerance) + + // borderline: covers mintAmounts but not the amount0Max/amount1Max the manager settles + const borderline = getV4AddLiquidityGasEstimateTransactions({ + ...baseParams, + currency0: { + allowance: desired.amount0.toString(), + permit2Allowance: { amount: desired.amount0.toString(), expiration: deadline }, + }, + currency1: { + allowance: maximums.amount1.toString(), + permit2Allowance: { amount: maximums.amount1.toString(), expiration: deadline }, + }, + }) + // ERC-20 + Permit2 approvals for currency0 still included + expect(borderline).toHaveLength(3) + expect(borderline[0].to).toEqual(currency0.address) + expect(borderline[1].to).toEqual(PERMIT2_ADDRESS) + + const covered = getV4AddLiquidityGasEstimateTransactions({ + ...baseParams, + currency0: { + allowance: maximums.amount0.toString(), + permit2Allowance: { amount: maximums.amount0.toString(), expiration: deadline }, + }, + currency1: { + allowance: maximums.amount1.toString(), + permit2Allowance: { amount: maximums.amount1.toString(), expiration: deadline }, + }, + }) + expect(covered).toHaveLength(1) + }) + + it('includes a Permit2 approval when the existing permit expires before the deadline', () => { + const transactions = getV4AddLiquidityGasEstimateTransactions({ + ...baseParams, + currency0: { + allowance: (10n ** 24n).toString(), + permit2Allowance: { amount: MaxAllowanceTransferAmount.toString(), expiration: '1799999999' }, + }, + currency1: { + allowance: (10n ** 24n).toString(), + permit2Allowance: { amount: MaxAllowanceTransferAmount.toString(), expiration: deadline }, + }, + }) + + expect(transactions).toHaveLength(2) + expect(transactions[0].to).toEqual(PERMIT2_ADDRESS) + expect(transactions[1].to).toEqual(positionManager) + }) + + it('skips approvals for the native side and attaches value for native pools', () => { + const eth = Ether.onChain(1) + const nativePool = new Pool(eth, currency1, 3000, 60, EMPTY_HOOK, encodeSqrtRatioX96(1, 1), 0, 0) + + const transactions = getV4AddLiquidityGasEstimateTransactions({ + ...baseParams, + pool: nativePool, + independentAmount: CurrencyAmount.fromRawAmount(eth, (10n ** 18n).toString()), + }) + + // ERC-20 + Permit2 approvals for currency1 only, then the mint carrying native value + expect(transactions).toHaveLength(3) + expect(transactions[0].to).toEqual(currency1.address) + expect(transactions[1].to).toEqual(PERMIT2_ADDRESS) + expect(transactions[2].to).toEqual(positionManager) + expect(transactions[2].value).not.toEqual('0x00') + + // native v4 pools default to the tighter native slippage tolerance + const expectedNativePosition = Position.fromAmount0({ + pool: nativePool, + tickLower: -60, + tickUpper: 60, + amount0: independentAmount.quotient, + useFullPrecision: true, + }) + const expected = V4PositionManager.addCallParameters(expectedNativePosition, { + recipient, + createPool: undefined, + sqrtPriceX96: undefined, + slippageTolerance: new Percent(5, 10_000), + deadline, + useNative: eth, + }) + expect(transactions[2].calldata).toEqual(expected.calldata) + expect(transactions[2].value).toEqual(expected.value) + }) + + it('produces an increase transaction when tokenId is set', () => { + const transactions = getV4AddLiquidityGasEstimateTransactions({ + ...baseParams, + recipient: undefined, + tokenId: '42', + }) + + const expected = V4PositionManager.addCallParameters(expectedPosition, { + tokenId: '42', + slippageTolerance: new Percent(250, 10_000), + deadline, + useNative: undefined, + }) + expect(transactions[4]).toEqual({ to: positionManager, calldata: expected.calldata, value: expected.value }) + }) + + it('throws when minting without a recipient', () => { + expect(() => getV4AddLiquidityGasEstimateTransactions({ ...baseParams, recipient: undefined })).toThrow( + 'NO_RECIPIENT' + ) + }) +}) diff --git a/sdks/lp-sdk/src/v4.ts b/sdks/lp-sdk/src/v4.ts new file mode 100644 index 000000000..f9822a108 --- /dev/null +++ b/sdks/lp-sdk/src/v4.ts @@ -0,0 +1,139 @@ +import { permit2Address } from '@uniswap/permit2-sdk' +import { BigintIsh, CHAIN_TO_ADDRESSES_MAP, Currency, CurrencyAmount, NativeCurrency, Percent } from '@uniswap/sdk-core' +import { AddLiquidityOptions, Pool, Position, V4PositionManager } from '@uniswap/v4-sdk' +import invariant from 'tiny-invariant' +import { DEFAULT_LP_SLIPPAGE_TOLERANCE, DEFAULT_NATIVE_V4_SLIPPAGE_TOLERANCE } from './constants' +import { LpGasEstimateTransaction, Permit2AllowanceInput } from './types' +import { erc20ApprovalTransactions, permit2ApprovalTransactions } from './utils/approvals' + +export interface V4AddLiquidityGasEstimateParams { + /** + * The pool to add liquidity to, carrying current price, liquidity, and tick. Native + * currency pools are supported directly (currency0 may be native). + */ + pool: Pool + tickLower: number + tickUpper: number + /** + * The side of the pool the estimate is driven by, typically the output of + * {@link pickPreEstimateIndependentAmount}. The other side's amount is derived + * from the pool price and the tick range. + */ + independentAmount: CurrencyAmount + /** + * The account minting the position. Required when creating a new position + * (no `tokenId`). + */ + recipient?: string + /** + * When set, produces an increase of the existing position instead of a mint. + */ + tokenId?: BigintIsh + /** + * Whether to prepend pool initialization to the mint call. The pool's current + * sqrt price is used as the initialization price. + */ + createPool?: boolean + /** + * Defaults to {@link DEFAULT_NATIVE_V4_SLIPPAGE_TOLERANCE} when the pool has a + * native side, otherwise {@link DEFAULT_LP_SLIPPAGE_TOLERANCE}. + */ + slippageTolerance?: Percent + /** + * When the transaction expires, in epoch seconds. Also used as the expiration for + * any Permit2 approval produced, and as the reference time when deciding whether an + * existing Permit2 allowance is still active. + */ + deadline: BigintIsh + /** + * Allowance state of pool.currency0 (ERC-20 allowance for Permit2, Permit2 + * allowance for the position manager). Zero allowances are assumed when omitted, + * yielding the worst-case estimate. + */ + currency0?: Permit2AllowanceInput + /** + * Allowance state of pool.currency1. + */ + currency1?: Permit2AllowanceInput +} + +/** + * Returns the ordered list of transactions a wallet must send to create or increase a + * Uniswap v4 position, for use with `eth_estimateGas`: any required ERC-20 approvals + * to Permit2 and Permit2 approvals to the v4 position manager, followed by the + * mint/increase transaction itself. + * + * The position transaction is encoded with `V4PositionManager.addCallParameters`, so + * the calldata matches what standard SDK consumers (including the Uniswap Labs + * liquidity service) produce for the transaction users actually sign. Note the signing + * flow may carry a signed Permit2 batch permit inside the position transaction instead + * of onchain Permit2 approvals; this helper estimates the approval-transaction path. + */ +export function getV4AddLiquidityGasEstimateTransactions( + params: V4AddLiquidityGasEstimateParams +): LpGasEstimateTransaction[] { + const { pool, tickLower, tickUpper, independentAmount, recipient, tokenId, createPool, deadline } = params + + const positionManagerAddress = ( + CHAIN_TO_ADDRESSES_MAP as Partial> + )[pool.chainId]?.v4PositionManagerAddress + invariant(positionManagerAddress !== undefined, 'NO_POSITION_MANAGER_ON_CHAIN') + + // v4 pools hold native currencies directly, so spending native is implied by the pool + const useNative = pool.currency0.isNative ? (pool.currency0 as NativeCurrency) : undefined + + let position: Position + if (independentAmount.currency.equals(pool.currency0)) { + position = Position.fromAmount0({ + pool, + tickLower, + tickUpper, + amount0: independentAmount.quotient, + useFullPrecision: true, + }) + } else { + invariant(independentAmount.currency.equals(pool.currency1), 'INDEPENDENT_NOT_IN_POOL') + position = Position.fromAmount1({ pool, tickLower, tickUpper, amount1: independentAmount.quotient }) + } + + const slippageTolerance = + params.slippageTolerance ?? + (pool.currency0.isNative || pool.currency1.isNative + ? DEFAULT_NATIVE_V4_SLIPPAGE_TOLERANCE + : DEFAULT_LP_SLIPPAGE_TOLERANCE) + + let options: AddLiquidityOptions + if (tokenId !== undefined) { + options = { tokenId, slippageTolerance, deadline, useNative } + } else { + invariant(recipient !== undefined, 'NO_RECIPIENT') + options = { + recipient, + createPool, + sqrtPriceX96: createPool ? pool.sqrtRatioX96 : undefined, + slippageTolerance, + deadline, + useNative, + } + } + const { calldata, value } = V4PositionManager.addCallParameters(position, options) + + // v4 settles up to the slippage-inflated maximums (amount0Max/amount1Max in the + // encoded call), so allowances must cover those — not just the desired amounts + const { amount0, amount1 } = position.mintAmountsWithSlippage(slippageTolerance) + const transactions: LpGasEstimateTransaction[] = [] + const sides = [ + { currency: pool.currency0, amount: amount0, input: params.currency0 }, + { currency: pool.currency1, amount: amount1, input: params.currency1 }, + ] + for (const { currency, amount, input } of sides) { + if (currency.isNative) continue + const token = currency.wrapped.address + transactions.push(...erc20ApprovalTransactions(token, amount, permit2Address(pool.chainId), input)) + transactions.push( + ...permit2ApprovalTransactions(token, amount, positionManagerAddress, pool.chainId, deadline, input) + ) + } + transactions.push({ to: positionManagerAddress, calldata, value }) + return transactions +} diff --git a/sdks/lp-sdk/tsconfig.base.json b/sdks/lp-sdk/tsconfig.base.json new file mode 100644 index 000000000..c9777bfed --- /dev/null +++ b/sdks/lp-sdk/tsconfig.base.json @@ -0,0 +1,28 @@ +{ + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.spec.ts"], + "compilerOptions": { + "rootDir": ".", + "baseUrl": ".", + "target": "es2018", + "module": "esnext", + "importHelpers": true, + "declaration": true, + "sourceMap": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true + } +} diff --git a/sdks/lp-sdk/tsconfig.cjs.json b/sdks/lp-sdk/tsconfig.cjs.json new file mode 100644 index 000000000..dc284e3b8 --- /dev/null +++ b/sdks/lp-sdk/tsconfig.cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "dist/cjs" + } +} diff --git a/sdks/lp-sdk/tsconfig.esm.json b/sdks/lp-sdk/tsconfig.esm.json new file mode 100644 index 000000000..85eabb15c --- /dev/null +++ b/sdks/lp-sdk/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "outDir": "dist/esm" + } +} diff --git a/sdks/lp-sdk/tsconfig.types.json b/sdks/lp-sdk/tsconfig.types.json new file mode 100644 index 000000000..27e26179b --- /dev/null +++ b/sdks/lp-sdk/tsconfig.types.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist/types" + } +}