diff --git a/README.md b/README.md index 87c1f6c..0653510 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ const wallet = new Wallet(privateKey); const orionUnit = initOrionUnit(chain, env); // Make market swap -orionUnit +orionUnit.exchange .swapMarket({ type: "exactSpend", assetIn: "ORN", @@ -153,7 +153,7 @@ orionUnit.orionAggregator.ws.subscribe( d: swapRequestId, // generated by client i: assetIn, // asset in o: assetOut, // asset out - e: true, // true when type of swap is exactSpend, can be omitted (true bu default) + e: true, // true when type of swap is exactSpend, can be omitted (true by default) a: 5.62345343, // amount }, // Handle data update in your way diff --git a/package-lock.json b/package-lock.json index 46bec34..10d3097 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@orionprotocol/sdk", - "version": "1.0.85-new-balances", + "version": "0.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@orionprotocol/sdk", - "version": "1.0.85-new-balances", + "version": "0.2.0", "license": "ISC", "dependencies": { "@ethersproject/abstract-signer": "^5.6.0", diff --git a/package.json b/package.json index 7bed910..fb2bfaa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@orionprotocol/sdk", - "version": "0.1.1", + "version": "0.2.0", "description": "Orion Protocol SDK", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/BalanceGuard.ts b/src/BalanceGuard.ts index 84126eb..e74f7c0 100644 --- a/src/BalanceGuard.ts +++ b/src/BalanceGuard.ts @@ -4,41 +4,9 @@ import clone from 'just-clone'; import { contracts, utils } from '.'; import { APPROVE_ERC20_GAS_LIMIT, NATIVE_CURRENCY_PRECISION } from './constants'; import { - AggregatedBalanceRequirement, Asset, BalanceIssue, BalanceRequirement, Source, + AggregatedBalanceRequirement, Approve, Asset, BalanceIssue, BalanceRequirement, Source, } from './types'; - -const arrayEquals = (a: unknown[], b: unknown[]) => a.length === b.length - && a.every((value, index) => value === b[index]); - -// By asset + sources + spender -const aggregateBalanceRequirements = (requirements: BalanceRequirement[]) => requirements - .reduce((prev, curr) => { - const aggregatedBalanceRequirement = prev.find( - (item) => item.asset.address === curr.asset.address - && arrayEquals(item.sources, curr.sources) - && item.spenderAddress === curr.spenderAddress, - ); - - if (aggregatedBalanceRequirement) { - aggregatedBalanceRequirement.items = { - ...aggregatedBalanceRequirement.items, - [curr.reason]: curr.amount, - }; - return prev; - } - return [ - ...prev, - { - asset: curr.asset, - sources: curr.sources, - spenderAddress: curr.spenderAddress, - items: { - [curr.reason]: curr.amount, - }, - }, - - ]; - }, []); +import arrayEquals from './utils/arrayEquals'; export default class BalanceGuard { private readonly balances: Partial< @@ -56,18 +24,18 @@ export default class BalanceGuard { private readonly provider: ethers.providers.Provider; - private readonly walletAddress: string; + private readonly signer: ethers.Signer; constructor( balances: Partial>>, nativeCryptocurrency: Asset, provider: ethers.providers.Provider, - walletAddress: string, + signer: ethers.Signer, ) { this.balances = balances; this.nativeCryptocurrency = nativeCryptocurrency; this.provider = provider; - this.walletAddress = walletAddress; + this.signer = signer; } registerRequirement(expense: BalanceRequirement) { @@ -84,8 +52,8 @@ export default class BalanceGuard { private async checkResetRequired( assetAddress: string, spenderAddress: string, - walletAddress: string, ) { + const walletAddress = await this.signer.getAddress(); const tokenContract = contracts.ERC20__factory .connect(assetAddress, this.provider); const unsignedTx = await tokenContract.populateTransaction @@ -103,9 +71,97 @@ export default class BalanceGuard { return resetRequired; } - async check() { + // By asset + sources + spender + static aggregateBalanceRequirements(requirements: BalanceRequirement[]) { + return requirements + .reduce((prev, curr) => { + const aggregatedBalanceRequirement = prev.find( + (item) => item.asset.address === curr.asset.address + && arrayEquals(item.sources, curr.sources) + && item.spenderAddress === curr.spenderAddress, + ); + + if (aggregatedBalanceRequirement) { + aggregatedBalanceRequirement.items = { + ...aggregatedBalanceRequirement.items, + [curr.reason]: curr.amount, + }; + return prev; + } + return [ + ...prev, + { + asset: curr.asset, + sources: curr.sources, + spenderAddress: curr.spenderAddress, + items: { + [curr.reason]: curr.amount, + }, + }, + + ]; + }, []); + } + + private fixAllAutofixableBalanceIssues = async ( + balanceIssues: BalanceIssue[], + ) => { + const fixBalanceIssue = async (issue: BalanceIssue) => { + const tokenContract = contracts.ERC20__factory.connect(issue.asset.address, this.provider); + const approve = async ({ spenderAddress, targetAmount }: Approve) => { + const bnTargetAmount = new BigNumber(targetAmount); + const unsignedApproveTx = await tokenContract + .populateTransaction + .approve( + spenderAddress, + bnTargetAmount.isZero() + ? '0' // Reset + : ethers.constants.MaxUint256, // Infinite approve + ); + + const walletAddress = await this.signer.getAddress(); + const nonce = await this.provider.getTransactionCount(walletAddress, 'pending'); + const gasPrice = await this.provider.getGasPrice(); + const network = await this.provider.getNetwork(); + + unsignedApproveTx.chainId = network.chainId; + unsignedApproveTx.gasPrice = gasPrice; + unsignedApproveTx.nonce = nonce; + unsignedApproveTx.from = walletAddress; + const gasLimit = await this.provider.estimateGas(unsignedApproveTx); + unsignedApproveTx.gasLimit = gasLimit; + + const signedTx = await this.signer.signTransaction(unsignedApproveTx); + const txResponse = await this.provider.sendTransaction(signedTx); + console.log(`${issue.asset.name} approve transaction sent ${txResponse.hash}. Waiting for confirmation...`); + await txResponse.wait(); + console.log(`${issue.asset.name} approve transaction confirmed.`); + }; + await issue.approves?.reduce(async (promise, item) => { + await promise; + return approve(item); + }, Promise.resolve()); + }; + + const autofixableBalanceIssues = balanceIssues.filter((balanceIssue) => balanceIssue.approves); + + await autofixableBalanceIssues.reduce(async (promise, item) => { + await promise; + return fixBalanceIssue(item); + }, Promise.resolve()); + + return balanceIssues.filter((item) => !autofixableBalanceIssues.includes(item)); + }; + + async check(fixAutofixable?: boolean) { + console.log(`Balance requirements: ${this.requirements + .map((requirement) => `${requirement.amount} ${requirement.asset.name} ` + + `for '${requirement.reason}' ` + + `from [${requirement.sources.join(' + ')}]`) + .join(', ')}`); + const remainingBalances = clone(this.balances); - const aggregatedRequirements = aggregateBalanceRequirements(this.requirements); + const aggregatedRequirements = BalanceGuard.aggregateBalanceRequirements(this.requirements); // Balance absorption order is important! // 1. Exchange-contract only @@ -193,7 +249,6 @@ export default class BalanceGuard { const resetRequired = await this.checkResetRequired( asset.address, spenderAddress, - this.walletAddress, ); const gasPriceWei = await this.provider.getGasPrice(); const approveTransactionCost = ethers.BigNumber @@ -269,7 +324,6 @@ export default class BalanceGuard { const resetRequired = await this.checkResetRequired( asset.address, spenderAddress, - this.walletAddress, ); const gasPriceWei = await this.provider.getGasPrice(); const approveTransactionCost = ethers.BigNumber @@ -332,6 +386,11 @@ export default class BalanceGuard { } }); - return balanceIssues; + if (fixAutofixable) { + const unfixed = await this.fixAllAutofixableBalanceIssues(balanceIssues); + if (unfixed.length > 0) throw new Error(`Balance issues: ${unfixed.map((issue, i) => `${i + 1}. ${issue.message}`).join('\n')}`); + } else if (balanceIssues.length > 0) { + throw new Error(`Balance issues: ${balanceIssues.map((issue, i) => `${i + 1}. ${issue.message}`).join('\n')}`); + } } } diff --git a/src/OrionUnit/Exchange/deposit.ts b/src/OrionUnit/Exchange/deposit.ts new file mode 100644 index 0000000..fee1a2e --- /dev/null +++ b/src/OrionUnit/Exchange/deposit.ts @@ -0,0 +1,136 @@ +/* eslint-disable max-len */ +import BigNumber from 'bignumber.js'; +import { ethers } from 'ethers'; +import getBalances from '../../utils/getBalances'; +import BalanceGuard from '../../BalanceGuard'; +import OrionUnit from '..'; +import { contracts, utils } from '../..'; +import { + DEPOSIT_ERC20_GAS_LIMIT, DEPOSIT_ETH_GAS_LIMIT, INTERNAL_ORION_PRECISION, NATIVE_CURRENCY_PRECISION, +} from '../../constants'; +import { normalizeNumber } from '../../utils'; + +export type DepositParams = { + asset: string, + amount: BigNumber.Value, + signer: ethers.Signer, + orionUnit: OrionUnit, +} + +export default async function deposit({ + asset, + amount, + signer, + orionUnit, +}: DepositParams) { + if (asset === '') throw new Error('Asset can not be empty'); + + const amountBN = new BigNumber(amount); + if (amountBN.isNaN()) throw new Error(`Amount '${amount.toString()}' is not a number`); + if (amountBN.lte(0)) throw new Error(`Amount '${amount.toString()}' should be greater than 0`); + + const walletAddress = await signer.getAddress(); + + const { + orionBlockchain, orionAggregator, provider, chainId, + } = orionUnit; + const { + exchangeContractAddress, + assetToAddress, + } = await orionBlockchain.getInfo(); + const addressToAsset = Object + .entries(assetToAddress) + .reduce>>((prev, [assetName, address]) => { + if (!address) return prev; + return { + ...prev, + [address]: assetName, + }; + }, {}); + + const nativeCryptocurrency = addressToAsset[ethers.constants.AddressZero]; + if (!nativeCryptocurrency) throw new Error('Native cryptocurrency asset is not found'); + + const exchangeContract = contracts.Exchange__factory.connect(exchangeContractAddress, provider); + const gasPriceWei = await orionBlockchain.getGasPriceWei(); + + const assetAddress = assetToAddress[asset]; + if (!assetAddress) throw new Error(`Asset '${asset}' not found`); + + const balances = await getBalances( + { + [asset]: assetAddress, + [nativeCryptocurrency]: ethers.constants.AddressZero, + }, + orionAggregator, + walletAddress, + exchangeContract, + provider, + ); + + const balanceGuard = new BalanceGuard( + balances, + { + name: nativeCryptocurrency, + address: ethers.constants.AddressZero, + }, + provider, + signer, + ); + + balanceGuard.registerRequirement({ + reason: 'Amount', + asset: { + name: asset, + address: assetAddress, + }, + amount: amount.toString(), + spenderAddress: exchangeContractAddress, + sources: ['wallet'], + }); + + let unsignedTx: ethers.PopulatedTransaction; + if (asset === nativeCryptocurrency) { + unsignedTx = await exchangeContract.populateTransaction.deposit(); + unsignedTx.value = normalizeNumber(amount, NATIVE_CURRENCY_PRECISION, BigNumber.ROUND_CEIL); + unsignedTx.gasLimit = ethers.BigNumber.from(DEPOSIT_ETH_GAS_LIMIT); + } else { + unsignedTx = await exchangeContract.populateTransaction.depositAsset( + assetAddress, + normalizeNumber(amount, INTERNAL_ORION_PRECISION, BigNumber.ROUND_CEIL), + ); + unsignedTx.gasLimit = ethers.BigNumber.from(DEPOSIT_ERC20_GAS_LIMIT); + } + + const transactionCost = ethers.BigNumber.from(unsignedTx.gasLimit).mul(gasPriceWei); + const denormalizedTransactionCost = utils.denormalizeNumber(transactionCost, NATIVE_CURRENCY_PRECISION); + + balanceGuard.registerRequirement({ + reason: 'Network fee', + asset: { + name: nativeCryptocurrency, + address: ethers.constants.AddressZero, + }, + amount: denormalizedTransactionCost.toString(), + sources: ['wallet'], + }); + + unsignedTx.chainId = parseInt(chainId, 16); + unsignedTx.gasPrice = ethers.BigNumber.from(gasPriceWei); + unsignedTx.from = walletAddress; + + await balanceGuard.check(true); + + const nonce = await provider.getTransactionCount(walletAddress, 'pending'); + unsignedTx.nonce = nonce; + + const signedTx = await signer.signTransaction(unsignedTx); + const txResponse = await provider.sendTransaction(signedTx); + console.log(`Deposit tx sent: ${txResponse.hash}. Waiting for confirmation...`); + const txReceipt = await txResponse.wait(); + if (txReceipt.status) { + console.log('Deposit tx confirmed'); + } else { + console.log('Deposit tx failed'); + } +} diff --git a/src/OrionUnit/Exchange/index.ts b/src/OrionUnit/Exchange/index.ts new file mode 100644 index 0000000..879f31e --- /dev/null +++ b/src/OrionUnit/Exchange/index.ts @@ -0,0 +1,28 @@ +import OrionUnit from '..'; +import deposit, { DepositParams } from './deposit'; +import swapMarket, { SwapMarketParams } from './swapMarket'; + +type PureSwapMarketParams= Omit +type PureDepositParams= Omit + +export default class Exchange { + private readonly orionUnit: OrionUnit; + + constructor(orionUnit: OrionUnit) { + this.orionUnit = orionUnit; + } + + public swapMarket(params: PureSwapMarketParams) { + return swapMarket({ + ...params, + orionUnit: this.orionUnit, + }); + } + + public deposit(params: PureDepositParams) { + return deposit({ + ...params, + orionUnit: this.orionUnit, + }); + } +} diff --git a/src/OrionUnit/swapMarket.ts b/src/OrionUnit/Exchange/swapMarket.ts similarity index 75% rename from src/OrionUnit/swapMarket.ts rename to src/OrionUnit/Exchange/swapMarket.ts index 4240b1d..7647256 100644 --- a/src/OrionUnit/swapMarket.ts +++ b/src/OrionUnit/Exchange/swapMarket.ts @@ -1,13 +1,12 @@ /* eslint-disable max-len */ import BigNumber from 'bignumber.js'; import { ethers } from 'ethers'; -import getBalances from '../utils/getBalances'; -import BalanceGuard from '../BalanceGuard'; -import getAvailableSources from '../utils/getAvailableFundsSources'; -import { Approve, BalanceIssue } from '../types'; -import OrionUnit from '.'; -import { contracts, crypt, utils } from '..'; -import { INTERNAL_ORION_PRECISION, NATIVE_CURRENCY_PRECISION, SWAP_THROUGH_ORION_POOL_GAS_LIMIT } from '../constants'; +import getBalances from '../../utils/getBalances'; +import BalanceGuard from '../../BalanceGuard'; +import getAvailableSources from '../../utils/getAvailableFundsSources'; +import OrionUnit from '..'; +import { contracts, crypt, utils } from '../..'; +import { INTERNAL_ORION_PRECISION, NATIVE_CURRENCY_PRECISION, SWAP_THROUGH_ORION_POOL_GAS_LIMIT } from '../../constants'; export type SwapMarketParams = { type: 'exactSpend' | 'exactReceive', @@ -56,6 +55,7 @@ export default async function swapMarket({ const amountBN = new BigNumber(amount); if (amountBN.isNaN()) throw new Error(`Amount '${amount.toString()}' is not a number`); + if (amountBN.lte(0)) throw new Error(`Amount '${amount.toString()}' should be greater than 0`); const slippagePercentBN = new BigNumber(slippagePercent); if (slippagePercentBN.isNaN()) throw new Error(`Slippage percent '${slippagePercent.toString()}' is not a number`); @@ -117,7 +117,7 @@ export default async function swapMarket({ address: ethers.constants.AddressZero, }, provider, - walletAddress, + signer, ); const swapInfo = await orionAggregator.getSwapInfo(type, assetIn, assetOut, amount.toString()); @@ -134,40 +134,6 @@ export default async function swapMarket({ const percent = new BigNumber(slippagePercent).div(100); - const fixBalanceIssue = async (issue: BalanceIssue) => { - const tokenContract = contracts.ERC20__factory.connect(issue.asset.address, provider); - const approve = async ({ spenderAddress, targetAmount }: Approve) => { - const bnTargetAmount = new BigNumber(targetAmount); - const unsignedApproveTx = await tokenContract - .populateTransaction - .approve( - spenderAddress, - bnTargetAmount.isZero() - ? '0' // Reset - : ethers.constants.MaxUint256, // Infinite approve - ); - - const nonce = await provider.getTransactionCount(walletAddress, 'pending'); - - unsignedApproveTx.chainId = parseInt(chainId, 16); - unsignedApproveTx.gasPrice = ethers.BigNumber.from(gasPriceWei); - unsignedApproveTx.nonce = nonce; - unsignedApproveTx.from = walletAddress; - const gasLimit = await provider.estimateGas(unsignedApproveTx); - unsignedApproveTx.gasLimit = gasLimit; - - const signedTx = await signer.signTransaction(unsignedApproveTx); - const txResponse = await provider.sendTransaction(signedTx); - options?.logger?.(`${issue.asset.name} approve transaction sent ${txResponse.hash}. Waiting for confirmation...`); - await txResponse.wait(); - options?.logger?.(`${issue.asset.name} approve transaction confirmed.`); - }; - await issue.approves?.reduce(async (promise, item) => { - await promise; - return approve(item); - }, Promise.resolve()); - }; - if (swapInfo.isThroughPoolOptimal) { options?.logger?.('Swap through pool'); const pathAddresses = swapInfo.path.map((name) => { @@ -252,25 +218,7 @@ export default async function swapMarket({ }); } - options?.logger?.(`Balance requirements: ${balanceGuard.requirements - .map((requirement) => `${requirement.amount} ${requirement.asset.name} ` - + `for '${requirement.reason}' ` - + `from [${requirement.sources.join(' + ')}]`) - .join(', ')}`); - - const balanceIssues = await balanceGuard.check(); - const autofixableBalanceIssues = balanceIssues.filter((balanceIssue) => balanceIssue.approves); - const allBalanceIssuesIsAutofixable = autofixableBalanceIssues.length === balanceIssues.length; - if (!allBalanceIssuesIsAutofixable) options?.logger?.('Some balance issues is not autofixable'); - - if (!allBalanceIssuesIsAutofixable || (options !== undefined && !options.autoApprove)) { - throw new Error(`Balance issues: ${balanceIssues.map((issue, i) => `${i + 1}. ${issue.message}`).join('\n')}`); - } - - await autofixableBalanceIssues.reduce(async (promise, item) => { - await promise; - return fixBalanceIssue(item); - }, Promise.resolve()); + await balanceGuard.check(options?.autoApprove); const nonce = await provider.getTransactionCount(walletAddress, 'pending'); unsignedSwapThroughOrionPoolTx.nonce = nonce; @@ -374,25 +322,7 @@ export default async function swapMarket({ sources: getAvailableSources('orion_fee', feeAssetAddress, 'aggregator'), }); - options?.logger?.(`Balance requirements: ${balanceGuard.requirements - .map((requirement) => `${requirement.amount} ${requirement.asset.name} ` - + `for '${requirement.reason}' ` - + `from [${requirement.sources.join(' + ')}]`) - .join(', ')}`); - - const balanceIssues = await balanceGuard.check(); - const autofixableBalanceIssues = balanceIssues.filter((balanceIssue) => balanceIssue.approves); - const allBalanceIssuesIsAutofixable = autofixableBalanceIssues.length === balanceIssues.length; - if (!allBalanceIssuesIsAutofixable) options?.logger?.('Some balance issues is not autofixable'); - - if (!allBalanceIssuesIsAutofixable || (options !== undefined && !options.autoApprove)) { - throw new Error(`Balance issues: ${balanceIssues.map((issue, i) => `${i + 1}. ${issue.message}`).join('\n')}`); - } - - await autofixableBalanceIssues.reduce(async (promise, item) => { - await promise; - return fixBalanceIssue(item); - }, Promise.resolve()); + await balanceGuard.check(options?.autoApprove); const signedOrder = await crypt.signOrder( baseAssetAddress, diff --git a/src/OrionUnit/index.ts b/src/OrionUnit/index.ts index dc0e282..c5802c0 100644 --- a/src/OrionUnit/index.ts +++ b/src/OrionUnit/index.ts @@ -2,10 +2,9 @@ import { ethers } from 'ethers'; import { OrionAggregator } from '../services/OrionAggregator'; import { OrionBlockchain } from '../services/OrionBlockchain'; import { PriceFeed } from '../services/PriceFeed'; -import swapMarket, { SwapMarketParams } from './swapMarket'; import { SupportedChainId } from '../types'; +import Exchange from './Exchange'; -type PureSwapMarketParams= Omit export default class OrionUnit { public readonly env: string; @@ -19,6 +18,8 @@ export default class OrionUnit { public readonly priceFeed: PriceFeed; + public readonly exchange: Exchange; + public readonly apiUrl: string; constructor( @@ -35,12 +36,6 @@ export default class OrionUnit { this.orionBlockchain = new OrionBlockchain(apiUrl, chainId); this.orionAggregator = new OrionAggregator(apiUrl, chainId); this.priceFeed = new PriceFeed(apiUrl); - } - - public swapMarket(params: PureSwapMarketParams) { - return swapMarket({ - ...params, - orionUnit: this, - }); + this.exchange = new Exchange(this); } } diff --git a/src/utils/arrayEquals.ts b/src/utils/arrayEquals.ts new file mode 100644 index 0000000..41dcbde --- /dev/null +++ b/src/utils/arrayEquals.ts @@ -0,0 +1,4 @@ +const arrayEquals = (a: unknown[], b: unknown[]) => a.length === b.length + && a.every((value, index) => value === b[index]); + +export default arrayEquals;