diff --git a/packages/extension/src/libs/keyring/public-keyring.ts b/packages/extension/src/libs/keyring/public-keyring.ts index c9a2048e8..210b1d518 100644 --- a/packages/extension/src/libs/keyring/public-keyring.ts +++ b/packages/extension/src/libs/keyring/public-keyring.ts @@ -77,6 +77,26 @@ class PublicKeyRing { walletType: WalletType.mnemonic, isHardware: false, }; + allKeys["77hREDDaAiimedtD9bR1JDMgYLW3AA5yPvD91pvrueRp"] = { + address: "77hREDDaAiimedtD9bR1JDMgYLW3AA5yPvD91pvrueRp", + basePath: "m/44'/501'/0'/1", + name: "fake sol acc 1", + pathIndex: 0, + publicKey: "0x0", + signerType: SignerType.ed25519sol, + walletType: WalletType.mnemonic, + isHardware: false, + }; + allKeys["tQvduDby4rvC6VU4rSirhVWuRYxbJz3rvUrVMkUWsZP"] = { + address: "tQvduDby4rvC6VU4rSirhVWuRYxbJz3rvUrVMkUWsZP", + basePath: "m/44'/501'/0'/1", + name: "fake sol acc 2", + pathIndex: 0, + publicKey: "0x0", + signerType: SignerType.ed25519sol, + walletType: WalletType.mnemonic, + isHardware: false, + }; } return allKeys; } diff --git a/packages/extension/src/ui/action/views/swap/libs/send-transactions.ts b/packages/extension/src/ui/action/views/swap/libs/send-transactions.ts index 8254cab43..fba46812c 100644 --- a/packages/extension/src/ui/action/views/swap/libs/send-transactions.ts +++ b/packages/extension/src/ui/action/views/swap/libs/send-transactions.ts @@ -37,6 +37,7 @@ import BitcoinAPI from "@/providers/bitcoin/libs/api"; import SolanaAPI from "@/providers/solana/libs/api"; import { VersionedTransaction as SolanaVersionedTransaction, + Transaction as SolanaLegacyTransaction, PublicKey, SendTransactionError, } from "@solana/web3.js"; @@ -192,35 +193,88 @@ export const executeSwap = async ( // Execute each transaction in-order one-by-one for (const enkSolTx of enkSolTxs) { // Transform the Enkrypt representation of the transaction into the Solana lib's representation - const tx = SolanaVersionedTransaction.deserialize( - Buffer.from(enkSolTx.serialized, "base64") - ); - // Sign the transaction message - // Use the keyring running in the background script - const sigRes = await sendUsingInternalMessengers({ - method: InternalMethods.sign, - params: [bufferToHex(tx.message.serialize()), options.from], - }); + let serialized: Uint8Array; + switch (enkSolTx.kind) { + case "versioned": { + // Sign Versioned transaction + // (note: the transaction may already be signed by a third party, + // like Rango exchange) - // Did we fail to sign? - if (sigRes.error != null) { - throw new Error( - `Failed to sign Solana swap transaction: ${sigRes.error.code} ${sigRes.error.message}` - ); - } + const tx = SolanaVersionedTransaction.deserialize( + Buffer.from(enkSolTx.serialized, "base64") + ); + + // Sign the transaction message + // Use the keyring running in the background script + const sigRes = await sendUsingInternalMessengers({ + method: InternalMethods.sign, + params: [bufferToHex(tx.message.serialize()), options.from], + }); + + // Did we fail to sign? + if (sigRes.error != null) { + throw new Error( + `Failed to sign Solana versioned swap transaction: ${sigRes.error.code} ${sigRes.error.message}` + ); + } + + // Add signature to the transaction + tx.addSignature( + new PublicKey(options.network.displayAddress(options.from.address)), + hexToBuffer(JSON.parse(sigRes.result!)) + ); + + serialized = tx.serialize(); + + break; + } + + case "legacy": { + // Sign Versioned transaction + // (note: the transaction may already be signed by a third party, + // like Rango exchange) - // Add signature to the transaction - tx.addSignature( - new PublicKey(options.network.displayAddress(options.from.address)), - hexToBuffer(JSON.parse(sigRes.result!)) - ); + const tx = SolanaLegacyTransaction.from( + Buffer.from(enkSolTx.serialized, "base64") + ); + + // Sign the transaction message + // Use the keyring running in the background script + const sigRes = await sendUsingInternalMessengers({ + method: InternalMethods.sign, + params: [bufferToHex(tx.serialize()), options.from], + }); + + // Did we fail to sign? + if (sigRes.error != null) { + throw new Error( + `Failed to sign Solana legacy swap transaction: ${sigRes.error.code} ${sigRes.error.message}` + ); + } + + // Add signature to the transaction + tx.addSignature( + new PublicKey(options.network.displayAddress(options.from.address)), + hexToBuffer(JSON.parse(sigRes.result!)) + ); + + serialized = tx.serialize(); + + break; + } + + default: + enkSolTx.kind satisfies never; + throw new Error( + `Cannot send Solana transaction: unexpected kind ${enkSolTx.kind}` + ); + } // Send the transaction let txHash: string; try { - // TODO: don't skip preflight - txHash = await conn.sendRawTransaction(tx.serialize()); + txHash = await conn.sendRawTransaction(serialized); } catch (err) { // Log error info if possible // The Solana web3 library prompts you to call getLogs if your error is of type @@ -263,9 +317,6 @@ export const executeSwap = async ( network: options.network.name, }); - // TODO:get the status of the transaction? - // activity.status = // success | failed | pending - solTxHashes.push(txHash); } diff --git a/packages/extension/src/ui/action/views/swap/libs/solana-gasvals.ts b/packages/extension/src/ui/action/views/swap/libs/solana-gasvals.ts index 6a0109ab9..929699e19 100644 --- a/packages/extension/src/ui/action/views/swap/libs/solana-gasvals.ts +++ b/packages/extension/src/ui/action/views/swap/libs/solana-gasvals.ts @@ -2,7 +2,12 @@ import { GasFeeType, GasPriceTypes } from "@/providers/common/types"; import SolanaAPI from "@/providers/solana/libs/api"; import { SolanaNetwork } from "@/providers/solana/types/sol-network"; import { fromBase } from "@enkryptcom/utils"; -import { VersionedTransaction } from "@solana/web3.js"; +import { + VersionedTransaction as SolanaVersionedTransaction, + Transaction as SolanaLegacyTransaction, + VersionedMessage, + Message, +} from "@solana/web3.js"; import BigNumber from "bignumber.js"; import { toBN } from "web3-utils"; @@ -11,7 +16,7 @@ import { toBN } from "web3-utils"; * (not nice but convenient) */ export const getSolanaTransactionFees = async ( - txs: VersionedTransaction[], + txs: (SolanaVersionedTransaction | SolanaLegacyTransaction)[], network: SolanaNetwork, price: number, additionalFee: ReturnType @@ -21,8 +26,12 @@ export const getSolanaTransactionFees = async ( let latestBlockHash = await conn.getLatestBlockhash(); for (let i = 0, len = txs.length; i < len; i++) { const tx = txs[i]; + // Use the latest block hash in-case it's fallen too far behind - tx.message.recentBlockhash = latestBlockHash.blockhash; + // (can't change block hash if it's already signed) + if (!tx.signatures.length) { + updateBlockHash(tx, latestBlockHash.blockhash); + } // Not sure why but getFeeForMessage sometimes returns null, so we will retry // with small backoff in-case it helps @@ -36,27 +45,34 @@ export const getSolanaTransactionFees = async ( throw new Error( `Failed to get fee for Solana VersionedTransaction ${i + 1}` + ` after ${backoff.length} attempts.` + - ` Transaction block hash ${tx.message.recentBlockhash} possibly expired.` + ` Transaction block hash` + + `${getRecentBlockHash(tx)} possibly expired.` ); } if (backoff[attempt] > 0) { // wait before retrying - await new Promise((res) => { - return setTimeout(res, backoff[attempt]); - }); + await new Promise((res) => setTimeout(res, backoff[attempt])); } // Update the block hash in-case it caused 0 fees to be returned if (attempt > 0) { - latestBlockHash = await conn.getLatestBlockhash(); - tx.message.recentBlockhash = latestBlockHash.blockhash; + if (!tx.signatures.length) { + console.warn( + `Cannot update block hash for signed transaction` + + ` ${i + 1}, retrying getFeeForMessage using the same` + + ` block hash ${getRecentBlockHash(tx)}` + ); + } else { + latestBlockHash = await conn.getLatestBlockhash(); + updateBlockHash(tx, latestBlockHash.blockhash); + } } /** Base fee + priority fee (Don't know why this returns null sometimes) */ - const feeResult = await conn.getFeeForMessage(tx.message); + const feeResult = await conn.getFeeForMessage(getMessage(tx)); if (feeResult.value == null) { console.warn( - `Failed to get fee for Solana VersionedTransaction` + - ` ${i + 1}. Transaction block hash ${tx.message.recentBlockhash}` + + `Failed to get fee for Solana VersionedTransaction ${i + 1}.` + + ` Transaction block hash ${getRecentBlockHash(tx)}` + ` possibly expired. Attempt ${attempt + 1}/${backoff.length}.` ); } else { @@ -74,7 +90,6 @@ export const getSolanaTransactionFees = async ( // Convert from lamports to SOL const feesumsol = fromBase(feesumlamp.toString(), network.decimals); - // TODO: give different fees for different priority levels return { [GasPriceTypes.REGULAR]: { nativeValue: feesumsol, @@ -84,3 +99,47 @@ export const getSolanaTransactionFees = async ( }, }; }; + +function getRecentBlockHash( + tx: SolanaVersionedTransaction | SolanaLegacyTransaction +): string { + return getMessage(tx).recentBlockhash; +} + +function updateBlockHash( + tx: SolanaVersionedTransaction | SolanaLegacyTransaction, + recentBlockHash: string +): void { + switch ((tx as SolanaVersionedTransaction).version) { + case 0: + case "legacy": + (tx as SolanaVersionedTransaction).message.recentBlockhash = + recentBlockHash; + break; + case undefined: + (tx as SolanaLegacyTransaction).recentBlockhash = recentBlockHash; + break; + default: + throw new Error( + `Cannot set block hash for Solana transaction: unexpected Solana transaction` + + ` type ${Object.getPrototypeOf(tx).constructor.name}` + ); + } +} + +function getMessage( + tx: SolanaVersionedTransaction | SolanaLegacyTransaction +): Message | VersionedMessage { + switch ((tx as SolanaVersionedTransaction).version) { + case 0: + case "legacy": + return (tx as SolanaVersionedTransaction).message; + case undefined: + return (tx as SolanaLegacyTransaction).compileMessage(); + default: + throw new Error( + `Cannot get Solana transaction message: unexpected Solana transaction` + + ` type ${Object.getPrototypeOf(tx).constructor.name}` + ); + } +} diff --git a/packages/extension/src/ui/action/views/swap/libs/swap-txs.ts b/packages/extension/src/ui/action/views/swap/libs/swap-txs.ts index bd2a4db77..327fdc0c8 100644 --- a/packages/extension/src/ui/action/views/swap/libs/swap-txs.ts +++ b/packages/extension/src/ui/action/views/swap/libs/swap-txs.ts @@ -21,7 +21,10 @@ import BitcoinAPI from "@/providers/bitcoin/libs/api"; import { getTxInfo as getBTCTxInfo } from "@/providers/bitcoin/libs/utils"; import { toBN } from "web3-utils"; import { BTCTxInfo } from "@/providers/bitcoin/ui/types"; -import { VersionedTransaction as SolanaVersionedTransaction } from "@solana/web3.js"; +import { + VersionedTransaction as SolanaVersionedTransaction, + Transaction as SolanaLegacyTransaction, +} from "@solana/web3.js"; export const getSubstrateNativeTransation = async ( network: SubstrateNetwork, @@ -97,7 +100,7 @@ export const getEVMTransaction = async ( export const getSwapTransactions = async ( networkName: SupportedNetworkName, transactions: TransactionType[] -) => { +): Promise => { const netInfo = getNetworkInfoByName(networkName); const network = await getNetworkByName( networkName as unknown as NetworkNames @@ -112,12 +115,25 @@ export const getSwapTransactions = async ( const allTxs = await Promise.all(txPromises); return allTxs; } else if (netInfo.type === NetworkType.Solana) { - const solTxs = (transactions as EnkryptSolanaTransaction[]).map( - (enkSolTx) => - SolanaVersionedTransaction.deserialize( - Buffer.from(enkSolTx.serialized, "base64") - ) - ); + const solTxs: (SolanaVersionedTransaction | SolanaLegacyTransaction)[] = ( + transactions as EnkryptSolanaTransaction[] + ).map(function (enkSolTx) { + switch (enkSolTx.kind) { + case "legacy": + return SolanaLegacyTransaction.from( + Buffer.from(enkSolTx.serialized, "base64") + ); + case "versioned": + return SolanaVersionedTransaction.deserialize( + Buffer.from(enkSolTx.serialized, "base64") + ); + default: + enkSolTx.kind satisfies never; + throw new Error( + `Cannot deserialize Solana transaction: Unexpected kind: ${enkSolTx.kind}` + ); + } + }); return solTxs; } else if (netInfo.type === NetworkType.Substrate) { if (transactions.length > 1) diff --git a/packages/swap/package.json b/packages/swap/package.json index bc44610d3..89e60b9f4 100644 --- a/packages/swap/package.json +++ b/packages/swap/package.json @@ -18,6 +18,7 @@ "dependencies": { "@enkryptcom/types": "workspace:^", "@enkryptcom/utils": "workspace:^", + "@solana/spl-token": "^0.4.8", "@solana/web3.js": "^1.95.3", "bignumber.js": "^9.1.2", "eventemitter3": "^5.0.1", diff --git a/packages/swap/src/configs.ts b/packages/swap/src/configs.ts index efcefd83f..72ddf32d0 100644 --- a/packages/swap/src/configs.ts +++ b/packages/swap/src/configs.ts @@ -98,9 +98,19 @@ const TOKEN_LISTS: { [NetworkNames.Telos]: `https://raw.githubusercontent.com/enkryptcom/dynamic-data/main/swaplists/${SupportedNetworkName.Telos}.json`, }; +/** + * ```sh + * curl -sL https://raw.githubusercontent.com/enkryptcom/dynamic-data/main/swaplists/changelly.json | jq '.' -C | less -R + * ``` + */ const CHANGELLY_LIST = "https://raw.githubusercontent.com/enkryptcom/dynamic-data/main/swaplists/changelly.json"; +/** + * ```sh + * curl -sL https://raw.githubusercontent.com/enkryptcom/dynamic-data/main/swaplists/top-tokens.json | jq '.' -C | less -R + * ``` + */ const TOP_TOKEN_INFO_LIST = "https://raw.githubusercontent.com/enkryptcom/dynamic-data/main/swaplists/top-tokens.json"; diff --git a/packages/swap/src/index.ts b/packages/swap/src/index.ts index b1191c22b..c8b692ad3 100644 --- a/packages/swap/src/index.ts +++ b/packages/swap/src/index.ts @@ -102,10 +102,12 @@ class Swap extends EventEmitter { } private async init() { - if (TOKEN_LISTS[this.network]) + if (TOKEN_LISTS[this.network]) { this.tokenList = await fetch(TOKEN_LISTS[this.network]).then((res) => res.json() ); + } + this.topTokenInfo = await fetch(TOP_TOKEN_INFO_LIST).then((res) => res.json() ); @@ -117,7 +119,7 @@ class Swap extends EventEmitter { this.providers = [ new Jupiter(this.api as Web3Solana, this.network), new Rango(this.api as Web3Solana, this.network), - new Changelly(this.network), + new Changelly(this.api, this.network), ]; break; default: @@ -125,7 +127,7 @@ class Swap extends EventEmitter { this.providers = [ new OneInch(this.api as Web3Eth, this.network), new Paraswap(this.api as Web3Eth, this.network), - new Changelly(this.network), + new Changelly(this.api, this.network), new ZeroX(this.api as Web3Eth, this.network), new Rango(this.api as Web3Eth, this.network), ]; diff --git a/packages/swap/src/providers/changelly/index.ts b/packages/swap/src/providers/changelly/index.ts index 79ec0b849..74a7f0b59 100644 --- a/packages/swap/src/providers/changelly/index.ts +++ b/packages/swap/src/providers/changelly/index.ts @@ -1,7 +1,20 @@ +import type Web3Eth from "web3-eth"; import { v4 as uuidv4 } from "uuid"; import fetch from "node-fetch"; import { fromBase, toBase } from "@enkryptcom/utils"; import { numberToHex, toBN } from "web3-utils"; +import { + VersionedTransaction, + SystemProgram, + PublicKey, + TransactionMessage, + Connection, + TransactionInstruction, +} from "@solana/web3.js"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + createTransferInstruction as createSPLTransferInstruction, +} from "@solana/spl-token"; import { getQuoteOptions, MinMaxResponse, @@ -32,9 +45,30 @@ import { import { getTransfer } from "../../utils/approvals"; import supportedNetworks from "./supported"; -import { ChangellyCurrency } from "./types"; +import { + ChangellyApiCreateFixedRateTransactionParams, + ChangellyApiCreateFixedRateTransactionResult, + ChangellyApiGetFixRateForAmountParams, + ChangellyApiGetFixRateForAmountResult, + ChangellyApiGetFixRateParams, + ChangellyApiGetFixRateResult, + ChangellyApiGetStatusParams, + ChangellyApiGetStatusResult, + ChangellyApiResponse, + ChangellyApiValidateAddressParams, + ChangellyApiValidateAddressResult, + ChangellyCurrency, +} from "./types"; import estimateEVMGasList from "../../common/estimateGasList"; - +import { + getCreateAssociatedTokenAccountIdempotentInstruction, + getSPLAssociatedTokenAccountPubkey, + getTokenProgramOfMint, + solAccountExists, + SPL_TOKEN_ATA_ACCOUNT_SIZE_BYTES, +} from "../../utils/solana"; + +/** Enables debug logging in this file */ const DEBUG = false; const BASE_URL = "https://partners.mewapi.io/changelly-v2"; @@ -73,6 +107,8 @@ class Changelly extends ProviderClass { network: SupportedNetworkName; + web3: Web3Eth | Connection; + name: ProviderName; fromTokens: ProviderFromTokenResponse; @@ -84,8 +120,9 @@ class Changelly extends ProviderClass { contractToTicker: Record; - constructor(network: SupportedNetworkName) { + constructor(web3: Web3Eth | Connection, network: SupportedNetworkName) { super(); + this.web3 = web3; this.network = network; this.tokenList = []; this.name = ProviderName.changelly; @@ -105,8 +142,7 @@ class Changelly extends ProviderClass { } this.changellyList = await fetch(CHANGELLY_LIST).then((res) => res.json()); - /** Mapping of changelly network name -> enkrypt network name */ - /** changelly blockchain name -> enkrypt supported swap network name */ + /** Changelly blockchain name -> enkrypt supported swap network name */ const changellyToNetwork: Record = {}; // Generate mapping of changelly blockchain -> enkrypt blockchain Object.keys(supportedNetworks).forEach((net) => { @@ -180,36 +216,99 @@ class Changelly extends ProviderClass { ); } - private changellyRequest(method: string, params: any): Promise { - // TODO: timeoutes & retries? - return fetch(`${BASE_URL}`, { - method: "POST", - body: JSON.stringify({ - id: uuidv4(), - jsonrpc: "2.0", - method, - params, - }), - headers: { "Content-Type": "application/json" }, - }).then((res) => res.json()); + /** + * Make a HTTP request to the Changelly Json RPC API + * + * @param method JsonRPC request method + * @param params JsonRPC request parameters + * @param context Cancellable execution context + * @returns JsonRPC response, could be success or error + */ + private async changellyRequest( + method: string, + params: any, + context?: { signal?: AbortSignal } + ): Promise> { + const signal = context?.signal; + const aborter = new AbortController(); + function onAbort() { + // Pass context signal to the request signal + aborter.abort(signal!.reason); + } + function onTimeout() { + aborter.abort( + new Error(`Changelly API request timed out ${BASE_URL} ${method}`) + ); + } + function cleanup() { + // eslint-disable-next-line no-use-before-define + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + } + const timeout = setTimeout(onTimeout, 30_000); + signal?.addEventListener("abort", onAbort); + try { + const response = await fetch(BASE_URL, { + method: "POST", + signal: aborter.signal, + body: JSON.stringify({ + id: uuidv4(), + jsonrpc: "2.0", + method, + params, + }), + headers: [ + ["Content-Type", "application/json"], + ["Accept", "application/json"], + ], + }); + const json = (await response.json()) as ChangellyApiResponse; + return json; + } finally { + cleanup(); + } } - isValidAddress(address: string, ticker: string): Promise { - return this.changellyRequest("validateAddress", { + async isValidAddress(address: string, ticker: string): Promise { + const params: ChangellyApiValidateAddressParams = { currency: ticker, address, - }).then((response) => { - if (response.error) { - debug( - "isValidAddress", - `Error in response when validating address` + - ` address=${address}` + - ` err=${String(response.error.message)}` - ); - return false; - } - return response.result?.[0]?.result ?? false; - }); + }; + + /** @see https://docs.changelly.com/validate-address */ + const response = + await this.changellyRequest( + "validateAddress", + params + ); + + if (response.error) { + console.warn( + `Error validating address with via Changelly` + + ` code=${String(response.error.code)}` + + ` message=${String(response.error.message)}` + ); + return false; + } + + if (typeof response.result.result !== "boolean") { + console.warn( + 'Unexpected response to "validateAddress" call to Changelly.' + + ` Expected a response.result.result to be a boolean` + + ` but received response: ${JSON.stringify(response)}` + ); + return false; + } + + const isValid = response.result.result; + debug( + "isValidAddress", + `Changelly validateAddress result` + + ` address=${address}` + + ` ticker=${ticker}` + + ` isValid=${isValid}` + ); + return isValid; } getFromTokens() { @@ -221,71 +320,86 @@ class Changelly extends ProviderClass { return {}; } - getMinMaxAmount({ - fromToken, - toToken, - }: { - fromToken: TokenType; - toToken: TokenTypeTo; - }): Promise { + async getMinMaxAmount( + options: { fromToken: TokenType; toToken: TokenTypeTo }, + context?: { signal?: AbortSignal } + ): Promise { + const { fromToken, toToken } = options; + const signal = context?.signal; + const startedAt = Date.now(); - debug( - "getMinMaxAmount", - `Getting min and max of swap pair` + - ` fromToken=${fromToken.symbol}` + - ` toToken=${toToken.symbol}` - ); const emptyResponse = { minimumFrom: toBN("0"), maximumFrom: toBN("0"), minimumTo: toBN("0"), maximumTo: toBN("0"), }; - const method = "getFixRate"; - return this.changellyRequest(method, { - from: this.getTicker(fromToken, this.network), - to: this.getTicker( - toToken as TokenType, - toToken.networkInfo.name as SupportedNetworkName - ), - }) - .then((response) => { - if (response.error) return emptyResponse; - const result = response.result[0]; - const minMax = { - minimumFrom: toBN(toBase(result.minFrom, fromToken.decimals)), - maximumFrom: toBN(toBase(result.maxFrom, fromToken.decimals)), - minimumTo: toBN(toBase(result.minTo, toToken.decimals)), - maximumTo: toBN(toBase(result.maxTo, toToken.decimals)), - }; - debug( - "getMinMaxAmount", - `Successfully got min and max of swap pair` + - ` method=${method}` + - ` fromToken=${fromToken.symbol}` + - ` toToken=${toToken.symbol}` + - ` took=${(Date.now() - startedAt).toLocaleString()}ms` + + try { + const params: ChangellyApiGetFixRateParams = { + from: this.getTicker(fromToken, this.network), + to: this.getTicker( + toToken as TokenType, + toToken.networkInfo.name as SupportedNetworkName + ), + }; + + const response = + await this.changellyRequest( + "getFixRate", + params, + { signal } ); - return minMax; - }) - .catch((err: Error) => { - debug( - "getMinMaxAmount", - `Errored calling getFixRate to get the min and max of swap pair` + - ` method=${method}` + - ` fromToken=${fromToken.symbol}` + - ` toToken=${toToken.symbol}` + + + if (response.error) { + // JsonRPC ERR response + console.warn( + `Changelly "getFixRate" returned JSONRPC error response` + + ` fromToken=${fromToken.symbol} (${params.from})` + + ` toToken=${toToken.symbol} (${params.to})` + ` took=${(Date.now() - startedAt).toLocaleString()}ms` + - ` err=${String(err)}` + ` code=${String(response.error.code)}` + + ` message=${String(response.error.message)}` ); return emptyResponse; - }); + } + + // JsonRPC OK response + const result = response.result[0]; + const minMax = { + minimumFrom: toBN(toBase(result.minFrom, fromToken.decimals)), + maximumFrom: toBN(toBase(result.maxFrom, fromToken.decimals)), + minimumTo: toBN(toBase(result.minTo, toToken.decimals)), + maximumTo: toBN(toBase(result.maxTo, toToken.decimals)), + }; + debug( + "getMinMaxAmount", + `Successfully got min and max of swap pair` + + ` fromToken=${fromToken.symbol} (${params.from})` + + ` toToken=${toToken.symbol} (${params.to})` + + ` took=${(Date.now() - startedAt).toLocaleString()}ms` + ); + return minMax; + } catch (err) { + // HTTP request failed + console.warn( + `Errored calling Changelly JSONRPC HTTP API "getFixRate"` + + ` fromToken=${fromToken.symbol}` + + ` toToken=${toToken.symbol}` + + ` took=${(Date.now() - startedAt).toLocaleString()}ms` + + ` err=${String(err)}` + ); + return emptyResponse; + } } async getQuote( options: getQuoteOptions, - meta: QuoteMetaOptions + meta: QuoteMetaOptions, + context?: { signal?: AbortSignal } ): Promise { + const signal = context?.signal; + const startedAt = Date.now(); debug( @@ -361,91 +475,189 @@ class Changelly extends ProviderClass { if (quoteRequestAmount.toString() === "0") return null; - const method = "getFixRateForAmount"; - debug("getQuote", `Requesting changelly swap... method=${method}`); - return this.changellyRequest(method, { - from: this.getTicker(options.fromToken, this.network), - to: this.getTicker( - options.toToken as TokenType, - options.toToken.networkInfo.name as SupportedNetworkName - ), - amountFrom: fromBase( - quoteRequestAmount.toString(), - options.fromToken.decimals - ), - }) - .then(async (response) => { - debug("getQuote", `Received Changelly swap response method=${method}`); - if (response.error || !response.result || !response.result[0].id) { - debug( - "getQuote", - `No swap: response either contains error, no result or no id` + - ` method=${method}` + - ` took=${(Date.now() - startedAt).toLocaleString()}ms` - ); - return null; - } - const result = response.result[0]; - const evmGasLimit = - options.fromToken.address === NATIVE_TOKEN_ADDRESS && - options.fromToken.type === NetworkType.EVM - ? 21000 - : toBN(GAS_LIMITS.transferToken).toNumber(); - const retResponse: ProviderQuoteResponse = { - fromTokenAmount: quoteRequestAmount, - additionalNativeFees: toBN(0), - toTokenAmount: toBN( - toBase(result.amountTo, options.toToken.decimals) - ).sub(toBN(toBase(result.networkFee, options.toToken.decimals))), - provider: this.name, - quote: { - meta: { - ...meta, - changellyQuoteId: result.id, - changellynetworkFee: toBN( - toBase(result.networkFee, options.toToken.decimals) - ), - }, - options: { - ...options, - amount: quoteRequestAmount, - }, - provider: this.name, - }, - totalGaslimit: - options.fromToken.type === NetworkType.EVM ? evmGasLimit : 0, - minMax, - }; - debug( - "getQuote", - `Successfully processed Changelly swap response` + - ` took=${(Date.now() - startedAt).toLocaleString()}ms` + debug("getQuote", `Requesting changelly swap...`); + + try { + const params: ChangellyApiGetFixRateForAmountParams = { + from: this.getTicker(options.fromToken, this.network), + to: this.getTicker( + options.toToken as TokenType, + options.toToken.networkInfo.name as SupportedNetworkName + ), + amountFrom: fromBase( + quoteRequestAmount.toString(), + options.fromToken.decimals + ), + }; + + const response = + await this.changellyRequest( + "getFixRateForAmount", + params, + { signal } + ); + + debug("getQuote", `Received Changelly swap response`); + + if (response.error) { + console.warn( + `Changelly "getFixRateForAmount" returned JSONRPC error response,` + + ` returning no quotes` + + ` fromToken=${options.fromToken.symbol} (${params.from})` + + ` toToken=${options.toToken.symbol} (${params.to})` + + ` took=${(Date.now() - startedAt).toLocaleString()}ms` + + ` code=${String(response.error.code)}` + + ` message=${String(response.error.message)}` ); - return retResponse; - }) - .catch((err) => { - debug( - "getQuote", - `Changelly request failed` + - ` method=${method}` + + return null; + } + + if (!response.result || !response.result[0]?.id) { + console.warn( + `Changelly "getFixRateForAmount" response contains no quotes,` + + ` returning no quotes` + + ` fromToken=${options.fromToken.symbol} (${params.from})` + + ` toToken=${options.toToken.symbol} (${params.to})` + ` took=${(Date.now() - startedAt).toLocaleString()}ms` + - ` err=${String(err)}` + ` code=${String(response.error.code)}` + + ` message=${String(response.error.message)}`, + { ...response } ); return null; - }); + } + + // TODO: Do we want to warn here? or just debug log? or nothing? + if (response.result.length > 1) { + console.warn( + `Changelly "getFixRateForAmount" returned more than one quote, continuing with first quote` + + ` fromToken=${options.fromToken.symbol} (${params.from})` + + ` toToken=${options.toToken.symbol} (${params.to})` + + ` took=${(Date.now() - startedAt).toLocaleString()}ms` + + ` count=${response.result.length}ms`, + { ...response } + ); + } + + const [firstChangellyFixRateQuote] = response.result; + + const evmGasLimit = + options.fromToken.address === NATIVE_TOKEN_ADDRESS && + options.fromToken.type === NetworkType.EVM + ? 21000 + : toBN(GAS_LIMITS.transferToken).toNumber(); + + // `toBase` fails sometimes because Changelly returns more decimals than the token has + let toTokenAmountBase: string; + try { + toTokenAmountBase = toBase( + firstChangellyFixRateQuote.amountTo, + options.toToken.decimals + ); + } catch (err) { + console.warn( + `Changelly "getFixRateForAmount" "amountTo" possibly returned more` + + ` decimals than the token has, attempting to trim trailing decimals...` + + ` amountTo=${firstChangellyFixRateQuote.amountTo}` + + ` toTokenDecimals=${options.toToken.decimals}` + + ` err=${String(err)}` + ); + const original = firstChangellyFixRateQuote.amountTo; + // eslint-disable-next-line no-use-before-define + const [success, fixed] = trimDecimals( + original, + options.toToken.decimals + ); + if (!success) throw err; + const rounded = ( + BigInt(toBase(fixed, options.toToken.decimals)) - BigInt(1) + ).toString(); + toTokenAmountBase = rounded; + } + + // `toBase` fails sometimes because Changelly returns more decimals than the token has + let networkFeeBase: string; + try { + networkFeeBase = toBase( + firstChangellyFixRateQuote.networkFee, + options.toToken.decimals + ); + } catch (err) { + console.warn( + `Changelly "getFixRateForAmount" "networkFee" possibly returned more` + + ` decimals than the token has, attempting to trim trailing decimals...` + + ` networkFee=${firstChangellyFixRateQuote.networkFee}` + + ` toTokenDecimals=${options.toToken.decimals}` + + ` err=${String(err)}` + ); + const original = firstChangellyFixRateQuote.networkFee; + // eslint-disable-next-line no-use-before-define + const [success, fixed] = trimDecimals( + original, + options.toToken.decimals + ); + if (!success) throw err; + const rounded = ( + BigInt(toBase(fixed, options.toToken.decimals)) + BigInt(1) + ).toString(); + networkFeeBase = rounded; + } + + const providerQuoteResponse: ProviderQuoteResponse = { + fromTokenAmount: quoteRequestAmount, + additionalNativeFees: toBN(0), + toTokenAmount: toBN(toTokenAmountBase).sub(toBN(networkFeeBase)), + provider: this.name, + quote: { + meta: { + ...meta, + changellyQuoteId: firstChangellyFixRateQuote.id, + changellynetworkFee: toBN(networkFeeBase), + }, + options: { + ...options, + amount: quoteRequestAmount, + }, + provider: this.name, + }, + totalGaslimit: + options.fromToken.type === NetworkType.EVM ? evmGasLimit : 0, + minMax, + }; + + debug( + "getQuote", + `Successfully retrieved quote from Changelly via "getFixRateForAmount"` + + ` took=${(Date.now() - startedAt).toLocaleString()}ms` + ); + + return providerQuoteResponse; + } catch (err) { + console.warn( + `Errored getting quotes from Changelly via "getFixRateForAmount",` + + ` returning no quotes` + + ` took=${(Date.now() - startedAt).toLocaleString()}ms` + + ` err=${String(err)}` + ); + return null; + } } - getSwap(quote: SwapQuote): Promise { + async getSwap( + quote: SwapQuote, + context?: { signal?: AbortSignal } + ): Promise { + const signal = context?.signal; + const startedAt = Date.now(); - debug("getSwap", `Getting Changelly swap`); + debug("getSwap", `Requesting swap transaction from Changelly...`); if (!Changelly.isSupported(this.network)) { debug( "getSwap", - `No swap: Enkrypt does not support Changelly on the source network` + + `Enkrypt does not support Changelly on the source network, returning no swap` + ` srcNetwork=${this.network}` ); - return Promise.resolve(null); + return null; } if ( @@ -455,58 +667,81 @@ class Changelly extends ProviderClass { ) { debug( "getSwap", - `No swap: Enkrypt does not support Changelly on the destination network` + + `Enkrypt does not support Changelly on the destination network, returning no swap` + ` dstNetwork=${quote.options.toToken.networkInfo.name}` ); - return Promise.resolve(null); + return null; } - const method = "createFixTransaction"; - debug("getSwap", `Requesting Changelly swap... method=${method}`); - return this.changellyRequest("createFixTransaction", { - from: this.getTicker(quote.options.fromToken, this.network), - to: this.getTicker( - quote.options.toToken as TokenType, - quote.options.toToken.networkInfo.name as SupportedNetworkName - ), - refundAddress: quote.options.fromAddress, - address: quote.options.toAddress, - amountFrom: fromBase( - quote.options.amount.toString(), - quote.options.fromToken.decimals - ), - rateId: quote.meta.changellyQuoteId, - }) - .then(async (response) => { - debug("getSwap", `Received Changelly swap response method=${method}`); - if (response.error || !response.result.id) { - debug( - "getSwap", - `No swap: response either contains error or no id` + - ` method=${method}` + - ` took=${(Date.now() - startedAt).toLocaleString()}ms` - ); - return null; - } - const { result } = response; - let transaction: SwapTransaction; - if (quote.options.fromToken.type === NetworkType.EVM) { - if (quote.options.fromToken.address === NATIVE_TOKEN_ADDRESS) + try { + const params: ChangellyApiCreateFixedRateTransactionParams = { + from: this.getTicker(quote.options.fromToken, this.network), + to: this.getTicker( + quote.options.toToken as TokenType, + quote.options.toToken.networkInfo.name as SupportedNetworkName + ), + refundAddress: quote.options.fromAddress, + address: quote.options.toAddress, + amountFrom: fromBase( + quote.options.amount.toString(), + quote.options.fromToken.decimals + ), + rateId: quote.meta.changellyQuoteId, + }; + + const response = + await this.changellyRequest( + "createFixTransaction", + params, + { signal } + ); + + if (response.error) { + console.warn( + `Changelly "createFixTransaction" returned JSONRPC error response, returning no swap` + + ` fromToken=${quote.options.fromToken.symbol} (${params.from})` + + ` toToken=${quote.options.toToken.symbol} (${params.to})` + + ` took=${(Date.now() - startedAt).toLocaleString()}ms` + + ` code=${String(response.error.code)}` + + ` message=${String(response.error.message)}` + ); + return null; + } + + if (!response.result.id) { + console.warn( + `Changelly "createFixTransaction" response contains no id, returning no swap` + + ` fromToken=${quote.options.fromToken.symbol} (${params.from})` + + ` toToken=${quote.options.toToken.symbol} (${params.to})` + + ` took=${(Date.now() - startedAt).toLocaleString()}ms`, + { ...response } + ); + return null; + } + + let additionalNativeFees = toBN(0); + const changellyFixedRateTx = response.result; + let transaction: SwapTransaction; + switch (quote.options.fromToken.type) { + case NetworkType.EVM: { + debug("getSwap", `Preparing EVM transaction for Changelly swap`); + if (quote.options.fromToken.address === NATIVE_TOKEN_ADDRESS) { transaction = { from: quote.options.fromAddress, data: "0x", gasLimit: numberToHex(21000), - to: result.payinAddress, + to: changellyFixedRateTx.payinAddress, value: numberToHex(quote.options.amount), type: TransactionType.evm, }; - else + } else { transaction = getTransfer({ from: quote.options.fromAddress, contract: quote.options.fromToken.address, - to: result.payinAddress, + to: changellyFixedRateTx.payinAddress, value: quote.options.amount.toString(), }); + } const accurateGasEstimate = await estimateEVMGasList( [transaction], this.network @@ -516,75 +751,256 @@ class Changelly extends ProviderClass { const [txGaslimit] = accurateGasEstimate.result; transaction.gasLimit = txGaslimit; } - } else { + break; + } + case NetworkType.Solana: { + // TODO: finish implementing support for Solana + debug("getSwap", `Changelly is not supported on Solana at this time`); + if (true as any) return null; + + const latestBlockHash = await ( + this.web3 as Connection + ).getLatestBlockhash(); + + // Create a transaction to transfer this much of that token to that thing + let versionedTx: VersionedTransaction; + if (quote.options.fromToken.address === NATIVE_TOKEN_ADDRESS) { + debug( + "getSwap", + `Preparing Solana Changelly SOL swap transaction` + + ` quote.options.fromAddress=${quote.options.fromAddress}` + + ` latestBlockHash=${latestBlockHash.blockhash}` + + ` lastValidBlockHeight=${latestBlockHash.lastValidBlockHeight}` + + ` payinAddress=${changellyFixedRateTx.payinAddress}` + + ` lamports=${BigInt(quote.options.amount.toString())}` + ); + versionedTx = new VersionedTransaction( + new TransactionMessage({ + payerKey: new PublicKey(quote.options.fromAddress), + recentBlockhash: latestBlockHash.blockhash, + instructions: [ + SystemProgram.transfer({ + fromPubkey: new PublicKey(quote.options.fromAddress), + toPubkey: new PublicKey(changellyFixedRateTx.payinAddress), + lamports: BigInt(quote.options.amount.toString()), + }), + ], + }).compileToV0Message() + ); + } else { + const wallet = new PublicKey(quote.options.fromAddress); + const mint = new PublicKey(quote.options.fromToken.address); + const tokenProgramId = await getTokenProgramOfMint( + this.web3 as Connection, + mint + ); + const walletMintAta = getSPLAssociatedTokenAccountPubkey( + wallet, + mint, + tokenProgramId + ); + // TODO: is payin address an ATA or Wallet address? + const payinAta = new PublicKey(changellyFixedRateTx.payinAddress); + const amount = BigInt(quote.options.amount.toString()); + debug( + "getSwap", + // eslint-disable-next-line prefer-template + `Preparing Solana Changelly SPL token swap transaction` + + ` srcMint=${mint.toBase58()}` + + ` wallet=${wallet.toBase58()}` + + ` walletSrcMintAta=${tokenProgramId.toBase58()}` + + ` dstMintAta=${payinAta.toBase58()}` + + ` tokenProgramId=${tokenProgramId.toBase58()}` + + ` latestBlockHash=${latestBlockHash.blockhash}` + + ` lastValidBlockHeight=${latestBlockHash.lastValidBlockHeight}` + + ` payinAddress=${changellyFixedRateTx.payinAddress}` + + ` amount=${amount}` + ); + + // If the ATA account doesn't exist we need create it + const ataExists = await solAccountExists( + this.web3 as Connection, + payinAta + ); + + const instructions: TransactionInstruction[] = []; + if (ataExists) { + debug( + "getSwap", + `Payin ATA already exists. No need to create it.` + ); + } else { + debug("getSwap", `Payin ATA does not exist. Need to create it.`); + // TODO: finish implementing + const extraRentFee = await ( + this.web3 as Connection + ).getMinimumBalanceForRentExemption( + SPL_TOKEN_ATA_ACCOUNT_SIZE_BYTES + ); + const instruction = + getCreateAssociatedTokenAccountIdempotentInstruction({ + payerPubkey: wallet, + ataPubkey: payinAta, // TODO: we'd need to get the owner + ownerPubkey: new PublicKey("!! TODO !!"), + mintPubkey: mint, + systemProgramId: SystemProgram.programId, + tokenProgramId, + associatedTokenProgramId: ASSOCIATED_TOKEN_PROGRAM_ID, + }); + + instructions.push(instruction); + additionalNativeFees = additionalNativeFees.add( + toBN(extraRentFee) + ); + throw new Error("TODO: Finish implementing Changelly on Solana"); + } + + instructions.push( + createSPLTransferInstruction( + /** source */ walletMintAta, + /** destination */ payinAta, + /** owner */ wallet, + /** amount */ amount, + /** multiSigners */ [], + /** programId */ tokenProgramId + ) + ); + + versionedTx = new VersionedTransaction( + new TransactionMessage({ + payerKey: wallet, + recentBlockhash: latestBlockHash.blockhash, + instructions, + }).compileToV0Message() + ); + } + + transaction = { + type: TransactionType.solana, + from: quote.options.fromAddress, + to: changellyFixedRateTx.payinAddress, + serialized: Buffer.from(versionedTx.serialize()).toString("base64"), + kind: "versioned", + signed: false, + }; + break; + } + default: { transaction = { from: quote.options.fromAddress, - to: result.payinAddress, + to: changellyFixedRateTx.payinAddress, value: numberToHex(quote.options.amount), type: TransactionType.generic, }; + break; } - const fee = 1; - const retResponse: ProviderSwapResponse = { - fromTokenAmount: quote.options.amount, - provider: this.name, - toTokenAmount: toBN( - toBase(result.amountExpectedTo, quote.options.toToken.decimals) - ).sub(quote.meta.changellynetworkFee), - additionalNativeFees: toBN(0), - transactions: [transaction], - slippage: quote.meta.slippage || DEFAULT_SLIPPAGE, - fee, - getStatusObject: async ( - options: StatusOptions - ): Promise => ({ - options: { - ...options, - swapId: result.id, - }, - provider: this.name, - }), - }; - debug( - "getSwap", - `Successfully processed Changelly swap response` + - ` took=${(Date.now() - startedAt).toLocaleString()}ms` + } + + // `toBase` fails sometimes because Changelly returns more decimals than the token has + const fee = 1; + let baseToAmount: string; + try { + baseToAmount = toBase( + changellyFixedRateTx.amountExpectedTo, + quote.options.toToken.decimals ); - return retResponse; - }) - .catch((err) => { - debug( - "getSwap", - `Changelly request failed` + - ` method=${method}` + - ` took=${(Date.now() - startedAt).toLocaleString()}ms` + + } catch (err) { + console.warn( + `Changelly "createFixTransaction" "amountExpectedTo" possibly returned more` + + ` decimals than the token has, attempting to trim trailing decimals...` + + ` amountExpectedTo=${changellyFixedRateTx.amountExpectedTo}` + + ` toTokenDecimals=${quote.options.toToken.decimals}` + ` err=${String(err)}` ); - return null; - }); + const original = changellyFixedRateTx.amountExpectedTo; + // eslint-disable-next-line no-use-before-define + const [success, fixed] = trimDecimals( + original, + quote.options.toToken.decimals + ); + if (!success) throw err; + const rounded = ( + BigInt(toBase(fixed, quote.options.toToken.decimals)) - BigInt(1) + ).toString(); + baseToAmount = rounded; + } + + const retResponse: ProviderSwapResponse = { + fromTokenAmount: quote.options.amount, + provider: this.name, + toTokenAmount: toBN(baseToAmount).sub(quote.meta.changellynetworkFee), + additionalNativeFees, + transactions: [transaction], + slippage: quote.meta.slippage || DEFAULT_SLIPPAGE, + fee, + getStatusObject: async ( + options: StatusOptions + ): Promise => ({ + options: { + ...options, + swapId: changellyFixedRateTx.id, + }, + provider: this.name, + }), + }; + debug( + "getSwap", + `Successfully extracted Changelly swap transaction via "createFixTransaction"` + + ` took=${(Date.now() - startedAt).toLocaleString()}ms` + ); + return retResponse; + } catch (err) { + console.warn( + `Errored processing Changelly swap response, returning no swap` + + ` took=${(Date.now() - startedAt).toLocaleString()}ms` + + ` err=${String(err)}` + ); + return null; + } } - getStatus(options: StatusOptions): Promise { - return this.changellyRequest("getStatus", { + async getStatus(options: StatusOptions): Promise { + const params: ChangellyApiGetStatusParams = { id: options.swapId, - }).then(async (response) => { - if (response.error || !response.result) return TransactionStatus.pending; - const completedStatuses = ["finished"]; - const pendingStatuses = [ - "confirming", - "exchanging", - "sending", - "waiting", - "new", - ]; - const failedStatuses = ["failed", "refunded", "hold", "expired"]; - const status = response.result; - if (pendingStatuses.includes(status)) return TransactionStatus.pending; - if (completedStatuses.includes(status)) return TransactionStatus.success; - if (failedStatuses.includes(status)) return TransactionStatus.failed; - return TransactionStatus.pending; - }); + }; + const response = await this.changellyRequest( + "getStatus", + params + ); + + if (response.error || !response.result) return TransactionStatus.pending; + const completedStatuses = ["finished"]; + const pendingStatuses = [ + "confirming", + "exchanging", + "sending", + "waiting", + "new", + ]; + const failedStatuses = ["failed", "refunded", "hold", "expired"]; + const status = response.result; + if (pendingStatuses.includes(status)) return TransactionStatus.pending; + if (completedStatuses.includes(status)) return TransactionStatus.success; + if (failedStatuses.includes(status)) return TransactionStatus.failed; + return TransactionStatus.pending; } } +function trimDecimals( + value: string, + decimals: number +): [success: boolean, fixed: string] { + const original = value; + const parts = original.split("."); + if (parts.length !== 2) return [false, ""]; // More or less than one decimal, something else is wrong + // Possibly recoverable + const [integerPart, fractionPart] = parts; + if (fractionPart.length <= decimals) return [false, ""]; // Some other issue, decimals should be sufficient + const fractionTrimmed = fractionPart.slice(0, decimals); + const normalised = `${integerPart}.${fractionTrimmed}`; + // Round up one (higher price paid) since we lose precision + const rounded = (BigInt(toBase(normalised, decimals)) + BigInt(1)).toString(); + return [true, rounded]; +} + export default Changelly; diff --git a/packages/swap/src/providers/changelly/supported.ts b/packages/swap/src/providers/changelly/supported.ts index 88c5501c4..0158763c7 100644 --- a/packages/swap/src/providers/changelly/supported.ts +++ b/packages/swap/src/providers/changelly/supported.ts @@ -1,6 +1,6 @@ -import { PublicKey } from "@solana/web3.js"; import { isPolkadotAddress, isEVMAddress } from "../../utils/common"; import { SupportedNetworkName } from "../../types"; +// import { isValidSolanaAddress } from "../../utils/solana"; /** * Blockchain names: @@ -61,18 +61,13 @@ const supportedNetworks: { [SupportedNetworkName.Dogecoin]: { changellyName: "doge", }, - [SupportedNetworkName.Solana]: { - changellyName: "solana", - isAddress: (address: string) => { - try { - // eslint-disable-next-line no-new - new PublicKey(address); - return Promise.resolve(true); - } catch (err) { - return Promise.resolve(false); - } - }, - }, + // TODO: Support Solana + // [SupportedNetworkName.Solana]: { + // changellyName: "solana", + // async isAddress(address: string) { + // return isValidSolanaAddress(address); + // }, + // }, [SupportedNetworkName.Rootstock]: { changellyName: "rootstock", }, diff --git a/packages/swap/src/providers/changelly/types.ts b/packages/swap/src/providers/changelly/types.ts index 91c974f44..77bfe2383 100644 --- a/packages/swap/src/providers/changelly/types.ts +++ b/packages/swap/src/providers/changelly/types.ts @@ -17,3 +17,699 @@ export interface ChangellyCurrency { contractAddress?: string; token?: TokenType; } + +// Changelly's API is Json RPC + +export type ChangellyApiOkResponse = { + id: string | number; + jsonrpc: "2.0"; + result: T; + error?: undefined; +}; +export type ChangellyApiErrResponse = { + id: string | number; + jsonrpc: "2.0"; + result?: undefined; + error: { + message: string; + code: number; + }; +}; + +export type ChangellyApiResponse = + | ChangellyApiOkResponse + | ChangellyApiErrResponse; + +/** + * @see https://docs.changelly.com/validate-address#request + * + * @example + * ```sh + * # Valid address + * curl -sL https://partners.mewapi.io/changelly-v2 -X POST -H Accept:application/json -H Content-Type:application/json --data '{"id":"1","jsonrpc":"2.0","method":"validateAddress","params":{"currency":"sol","address":"CMGoYEKM8kSXwN9HzYiwRiZRXoMtEAQ98ZiPE9y67T38"}}' | jq '.' -C | less -R + * # { + * # "jsonrpc": "2.0", + * # "result": { + * # "result": true + * # }, + * # "id": "1" + * # } + * + * # Invalid address + * curl -sL https://partners.mewapi.io/changelly-v2 -X POST -H Accept:application/json -H Content-Type:application/json --data '{"id":"1","jsonrpc":"2.0","method":"validateAddress","params":{"currency":"sol","address":"CMGoYEKM8kSXwN9HzYiwRiZRXoMtEAQ98ZiPE9y67T38zzzzzz"}}' | jq '.' -C | less -R + * # { + * # "jsonrpc": "2.0", + * # "result": { + * # "result": false, + * # "message": "Invalid address" + * # }, + * # "id": "1" + * # } + * ``` + */ +export type ChangellyApiValidateAddressParams = { + /** + * Currency ticker (in lowercase). + * + * @example "sol" + */ + currency: string; + + /** + * Wallet address. + * + * @example "CMGoYEKM8kSXwN9HzYiwRiZRXoMtEAQ98ZiPE9y67T38" + */ + address: string; + + /** Extra ID. */ + extraId?: string; +}; + +/** @see https://docs.changelly.com/validate-address#response */ +export type ChangellyApiValidateAddressResult = { + /** + * Is true if the given address is valid. + */ + result: boolean; + + /** + * Error message which is returned only if the given address is invalid. + * + * @example "Invalid address" + */ + message?: string; +}; + +/** + * @see https://docs.changelly.com/fix/get-fix-rate#request + * + * @note The method is deprecated. Use getFixRateForAmount instead. + * + * @example + * ```sh + * curl -sL https://partners.mewapi.io/changelly-v2 -X POST -H Accept:application/json -H Content-Type:application/json --data '{"id":"1","jsonrpc":"2.0","method":"getFixRate","params":{"from":"sol","to":"btc"}}' | jq '.' -C | less -R + * # { + * # "jsonrpc": "2.0", + * # "result": [ + * # { + * # "id": "RmELQQZ@MP0WIcD55XEjyVmieqsk@z", + * # "from": "sol", + * # "to": "btc", + * # "result": "0.002190975020", + * # "networkFee": "0.00001909", + * # "max": "700.00000000", + * # "maxFrom": "700.00000000", + * # "maxTo": "1.57946710", + * # "min": "0.80000000", + * # "minFrom": "0.80000000", + * # "minTo": "0.00180510", + * # "expiredAt": 1726539462 + * # } + * # ], + * # "id": "1" + * # } + * ```` + */ +export type ChangellyApiGetFixRateParams = { + /** + * Payin currency code (in lowercase). + * + * @example "sol" + */ + from: string; + + /** + * Payout currency code (in lowercase). + * + * @example "btc" + * */ + to: string; +}; + +/** + * @see https://docs.changelly.com/fix/get-fix-rate#response + * + * @note The method is deprecated. Use getFixRateForAmount instead. + */ +export type ChangellyApiGetFixRateResult = Array<{ + /** + * Rate ID that can be used during 1 minute. + * This time should be enough for user to initiate the exchange. + * Expired rate id cannot be used for creation of the fixed rate transaction. + * + * id has to be stored somewhere. It will be used as rateId value while calling. + * + * @example "LT^rql0B^5QcM^pETcEZaBZ652v#*s" + */ + id: string; + + /** + * Exchange rate before withholding the network fee. + * + * Important. To calculate the exact amount that the user will get, you need + * to multiply the amount that the user wants to send to the result and deduct + * the value of the networkFee. + * + * @example "0.002191084078" + */ + result: string; + + /** + * Payin currency code. + * + * @example "sol" + */ + from: string; + + /** + * Payout currency code. + * + * @example "btc" + */ + to: string; + + /** + * Commission taken by the network from the amount sent to the user. + * For one-step exchange, displayed in pay-out currency. + * For two-step exchanges, displayed in BTC or in USDT. + * + * @example "0.00001909" + */ + networkFee: string; + + /** + * Maximum exchangeable amount. + * + * "700.00000000" + */ + max: string; + + /** + * Maximum payin amount for which we would be able to perform the exchange. + * + * @example "700.00000000" + */ + maxFrom: string; + + /** + * Maximum payout amount for which we would be able to perform the exchange. + * + * @example "1.57912539" + */ + maxTo: string; + + /** + * Minimum exchangeable amount. + * + * @example "0.80000000" + */ + min: string; + + /** + * Minimum payin amount for which we would be able to perform the exchange. + * + * @example "0.80000000" + */ + minFrom: string; + + /** + * Minimum payout amount for which we would be able to perform the exchange. + * + * @example "0.00180472" + */ + minTo: string; + + /** + * Unix timestamp in seconds (10 digits) representing the expiration time of the fixed rate. + * + * @example 1726538857 + */ + expiredAt: number; + + /** + * Exchange fee in pay-out currency. + */ + fee?: string; +}>; + +/** + * @see https://docs.changelly.com/fix/get-fix-rate-for-amount#request + * + * @example + * ```sh + * curl -sL https://partners.mewapi.io/changelly-v2 -X POST -H Accept:application/json -H Content-Type:application/json --data '{"id":"1","jsonrpc":"2.0","method":"getFixRateForAmount","params":{"from":"sol","to":"btc","amountFrom":"1"}}' | jq '.' -C | less -R + * # { + * # "jsonrpc": "2.0", + * # "result": [ + * # { + * # "id": "GhNY6BJBOsjt8UlfEOHI&x0B$m6Dde", + * # "from": "sol", + * # "to": "btc", + * # "result": "0.002191229654", + * # "networkFee": "0.00001909", + * # "max": "700.00000000", + * # "maxFrom": "700.00000000", + * # "maxTo": "1.57946710", + * # "min": "0.80000000", + * # "minFrom": "0.80000000", + * # "minTo": "0.00180510", + * # "amountFrom": "1", + * # "amountTo": "0.00217213", + * # "expiredAt": 1726539734 + * # } + * # ], + * # "id": "1" + * # } + * ``` + */ +export type ChangellyApiGetFixRateForAmountParams = { + /** + * Payin currency code (in lowercase). + * + * @example "sol" + */ + from: string; + + /** + * Payout currency code (in lowercase). + * + * @example "btc" + */ + to: string; + + /** + * Amount that user is going to exchange. + */ + amountFrom?: string; + + /** + * Amount that user is going to receive. + */ + amountTo?: string; + + /** + * Escaped JSON. + * You can use userMetadata to include any additional parameters for customization purposes. + * To use this feature, please contact us at pro@changelly.com. + */ + userMetadata?: string; +}; + +/** + * @see https://docs.changelly.com/fix/get-fix-rate-for-amount#response + */ +export type ChangellyApiGetFixRateForAmountResult = Array<{ + /** + * Rate ID that can be used during 1 minute. + * This time should be enough for user to initiate the exchange. + * Expired rate id cannot be used for creation of the fixed rate transaction. + * + * id has to be stored somewhere. It will be used as rateId value while calling. + * + * @example "FHzP%N~phM2h2&zCS$JilM)tEr!8oj" + */ + id: string; + + /** + * Exchange rate before withholding the network fee. + * + * Important. To calculate the exact amount that the user will get, + * you need to multiply the amount that the user wants to send to + * the result and deduct the value of the networkFee. + * + * @example "0.002191379964" + */ + result: string; + + /** + * Payin currency code. + * + * @example "sol" + */ + from: string; + + /** + * Payout currency code. + * + * @example "btc" + */ + to: string; + + /** + * Commission taken by the network from the amount sent to the user. Displayed in pay-out currency. + * + * @example "0.00001909" + */ + networkFee: string; + + /** + * Maximum exchangeable amount. + * + * @example "700.00000000" + */ + max: string; + + /** + * Maximum payin amount for which we would be able to perform the exchange. + * + * @example "700.00000000" + */ + maxFrom: string; + + /** + * Maximum payout amount for which we would be able to perform the exchange. + * + * @example "1.57946710" + */ + maxTo: string; + + /** + * Minimum exchangeable amount. + * + * @example "0.80000000" + */ + min: string; + + /** + * Minimum payin amount for which we would be able to perform the exchange. + * + * @example "0.80000000" + */ + minFrom: string; + + /** + * Minimum payout amount for which we would be able to perform the exchange. + * + * @example "0.00180510" + */ + minTo: string; + + /** + * Amount of assets that user will exchange after creating a fixed rate transaction. + * + * @example "1" + */ + amountFrom: string; + + /** + * Fixed exchange amount that user will receive after finishing a fixed-rate transaction using current rateId. + * + * @example "0.00217228" + */ + amountTo: string; + + /** + * Unix timestamp in seconds (10 digits) representing the expiration time of the fixed rate. + * + * @example 1726539789 + */ + expiredAt: number; + + /** + * Exchange fee in pay-out currency. + */ + fee?: string; +}>; + +/** + * @see https://docs.changelly.com/fix/create-fix-transaction#request + * + * @example + * ```sh + * # Replace "rateId" with "id" from "getFixRateForAmount" + * curl -sL https://partners.mewapi.io/changelly-v2 -X POST -H Accept:application/json -H Content-Type:application/json --data '{"id":"1","jsonrpc":"2.0","method":"createFixTransaction","params":{"from":"sol","to":"btc","amountFrom":"1","rateId":"l3zsI9C(1TDc5dRYOsG%fjJdu6BEuN","address":"bc1puzz9tmxawd7zdd7klfgtywrgpma3u22fz5ecxhucd4j8tygqe5ms2vdd9y","amountFrom":"1","refundAddress":"CMGoYEKM8kSXwN9HzYiwRiZRXoMtEAQ98ZiPE9y67T38"}}' | jq '.' -C | less -R + * # { + * # "jsonrpc": "2.0", + * # "result": { + * # "id": "ze6rnqcnnuxxd6hc", + * # "trackUrl": "https://changelly.com/track/ze6rnqcnnuxxd6hc", + * # "createdAt": 1726540614000000, + * # "type": "fixed", + * # "status": "new", + * # "payTill": "2024-09-17T02:51:54.173+00:00", + * # "currencyFrom": "sol", + * # "currencyTo": "btc", + * # "payinAddress": "CocKqBY2yF6hXDTaM5Y5WgMUHNNjagVzAxUEMfGwUHXm", + * # "amountExpectedFrom": "1", + * # "payoutAddress": "bc1puzz9tmxawd7zdd7klfgtywrgpma3u22fz5ecxhucd4j8tygqe5ms2vdd9y", + * # "refundAddress": "CMGoYEKM8kSXwN9HzYiwRiZRXoMtEAQ98ZiPE9y67T38", + * # "amountExpectedTo": "0.00219147", + * # "networkFee": "0.00001909" + * # }, + * # "id": "1" + * # } + * ``` + */ +export type ChangellyApiCreateFixedRateTransactionParams = { + /** + * Payin currency code (in lowercase). + * + * @example "btc" + */ + from: string; + + /** + * Payout currency code (in lowercase). + * + * @example "sol" + */ + to: string; + + /** + * Rate ID that you get from getFixRate/getFixRateForAmount requests. + * + * @see https://docs.changelly.com/fix/get-fix-rate + * @see https://docs.changelly.com/fix/get-fix-rate-for-amount + * + * @example "FHzP%N~phM2h2&zCS$JilM)tEr!8oj" + */ + rateId: string; + + /** + * Recipient address. + * + * @example "CMGoYEKM8kSXwN9HzYiwRiZRXoMtEAQ98ZiPE9y67T38" + */ + address: string; + + /** + * Amount of currency that user is going to send. + * + * @example "1" + */ + amountFrom?: string; + + /** + * Amount user wants to receive. + */ + amountTo?: string; + + /** + * Additional ID for address for currencies that use additional ID for transaction processing. + */ + extraId?: string; + + /** + * Address of the wallet to refund in case of any technical issues during the exchange. + * The currency of the wallet must match with the from currency. + * + * @example "CMGoYEKM8kSXwN9HzYiwRiZRXoMtEAQ98ZiPE9y67T38" + */ + refundAddress: string; + + /** + * extraId for refundAddress. + */ + refundExtraId?: string; + + /** + * Address of the wallet from which the user will send payin. + */ + fromAddress?: string; + + /** + * extraId for fromAddress. + */ + fromExtraId?: string; + + /** + * subaccountId from createSubaccount. + * + * @see https://docs.changelly.com/subaccounts/subaccount + */ + subaccountId?: string; + + /** + * Escaped JSON. + * You can use userMetadata to include any additional parameters for customization purposes. + * To use this feature, please contact us at pro@changelly.com. + */ + userMetadata?: string; +}; + +/** + * @see https://docs.changelly.com/fix/create-fix-transaction#response + */ +export type ChangellyApiCreateFixedRateTransactionResult = { + /** + * Not documented + * + * Contains a URL that you can visit to follow the transaction + * + * @example "https://changelly.com/track/ze6rnqcnnuxxd6hc" + */ + trackUrl: string; + + /** + * Transaction ID. Could be used in getStatus method. + * + * @see https://docs.changelly.com/info/get-status + * + * @example "ze6rnqcnnuxxd6hc" + */ + id: string; + + /** + * Type of transaction. Always fixed in this method. + * + * @example "fixed" + */ + type: string; + + /** + * Address for a user to send coins to. + * + * @example "CocKqBY2yF6hXDTaM5Y5WgMUHNNjagVzAxUEMfGwUHXm" + */ + payinAddress: string; + + /** + * Extra ID for payinAddress in case it is required. + * Note: If the payinExtraId parameter is returned in the response and is not null, + * it is required for user to send the funds to the payinAddress specifying extraId. + * Otherwise, the transactions will not be processed and the user will need to get a + * refund through technical support. + */ + payinExtraId?: string; + + /** + * Address where the exchange result will be sent to. + * + * @example "bc1puzz9tmxawd7zdd7klfgtywrgpma3u22fz5ecxhucd4j8tygqe5ms2vdd9y" + */ + payoutAddress: string; + + /** + * Extra ID for payoutAddress in case it is required. + */ + payoutExtraId?: string; + + /** + * Address of the wallet to refund in case of any technical issues during the exchange. + * The currency of the wallet must match with the from currency. + * + * @example "CMGoYEKM8kSXwN9HzYiwRiZRXoMtEAQ98ZiPE9y67T38" + */ + refundAddress: string; + + /** + * Extra ID for refundAddress. + */ + refundExtraId?: string; + + /** + * The amountFrom value from createFixTransaction request. + * + * @example "1" + */ + amountExpectedFrom: number; + + /** + * The amountTo value from getFixRateForAmount response. + * This is the estimated payout amount of currency before withholding the network fee. + * + * @see https://docs.changelly.com/fix/get-fix-rate-for-amount + * + * @example "0.00219147" + */ + amountExpectedTo: string; + + /** + * Transaction status. + * Will always be new when transaction is created. + * If you reference the same transaction using the getStatus or getTransactions method, + * you'll get the waiting status as an equivalent to new. + * + * @example "new" + */ + status: string; + + /** + * Indicates time until which user needs to make the payment. + * + * @example "2024-09-17T02:51:54.173+00:00" + */ + payTill: string; + + /** + * Payout currency code. + * + * @example "btc" + */ + currencyTo: string; + + /** + * Payin currency code. + * + * @example "sol" + */ + currencyFrom: string; + + /** + * Time in timestamp format (microseconds) when the transaction was created. + * + * @example 1726540614000000 + */ + createdAt: number; + + /** + * Commission taken by the network from the amount sent to the user. Displayed in payout currency. + * + * @example "0.00001909" + */ + networkFee: string; +}; + +/** + * @see https://docs.changelly.com/info/get-status#request + * + * @example + * ```sh + * # Replace "id" with "id" from "createFixTransaction" + * curl -sL https://partners.mewapi.io/changelly-v2 -X POST -H Accept:application/json -H Content-Type:application/json --data '{"id":"1","jsonrpc":"2.0","method":"getStatus","params":{"id":"ze6rnqcnnuxxd6hc"}}' | jq '.' -C | less -R + * # { + * # "jsonrpc": "2.0", + * # "result": "waiting", + * # "id": "1" + * # } + * ``` + */ + +export type ChangellyApiGetStatusParams = { + /** + * Transaction ID. + * + * @example "ze6rnqcnnuxxd6hc" + */ + id: string; +}; + +/** + * @see https://docs.changelly.com/info/get-status#response + * + * Transaction status. + * + * @example "waiting" + */ +export type ChangellyApiGetStatusResult = string; diff --git a/packages/swap/src/providers/jupiter/index.ts b/packages/swap/src/providers/jupiter/index.ts index 1edfefb9b..39c8c6c38 100644 --- a/packages/swap/src/providers/jupiter/index.ts +++ b/packages/swap/src/providers/jupiter/index.ts @@ -2,19 +2,27 @@ import { NetworkNames } from "@enkryptcom/types"; import { - AddressLookupTableAccount, - ComputeBudgetProgram, Connection, PublicKey, SystemProgram, TransactionInstruction, - TransactionMessage, VersionedTransaction, } from "@solana/web3.js"; import { toBN } from "web3-utils"; import fetch from "node-fetch"; import { TOKEN_AMOUNT_INFINITY_AND_BEYOND } from "../../utils/approvals"; -import { extractComputeBudget } from "../../utils/solana"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + extractComputeBudget, + getCreateAssociatedTokenAccountIdempotentInstruction, + getSPLAssociatedTokenAccountPubkey, + getTokenProgramOfMint, + insertInstructionsAtStartOfTransaction, + isValidSolanaAddressAsync, + solAccountExists, + SPL_TOKEN_ATA_ACCOUNT_SIZE_BYTES, + WRAPPED_SOL_ADDRESS, +} from "../../utils/solana"; import { ProviderClass, ProviderName, @@ -39,6 +47,12 @@ import { FEE_CONFIGS, NATIVE_TOKEN_ADDRESS, } from "../../configs"; +import { + JupiterQuoteResponse, + JupiterSwapParams, + JupiterSwapResponse, + JupiterTokenInfo, +} from "./types"; /** Enables debug logging in this file */ const DEBUG = false; @@ -96,12 +110,6 @@ const JUPITER_TOKENS_URL = "https://tokens.jup.ag/tokens?tags=verified"; */ const JUPITER_API_URL = "https://quote-api.jup.ag/v6/"; -/** - * Wrapped SOL address - * @see https://solscan.io/token/So11111111111111111111111111111111111111112 - */ -const WRAPPED_SOL_ADDRESS = "So11111111111111111111111111111111111111112"; - /** * @see https://solscan.io/account/45ruCyfdRkWpRNGEqWzjCiXRHkZs8WXCLQ67Pnpye7Hp * @@ -120,35 +128,6 @@ const JUPITER_REFERRAL_PROGRAM_PUBKEY = new PublicKey( "REFER4ZgmyYx9c6He5XfaTMiGfdLwRnkV4RPp9t9iF3" ); -/** - * Address of the SPL Token program - * - * @see https://solscan.io/account/TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA - */ -export const TOKEN_PROGRAM_ID = new PublicKey( - "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" -); - -/** - * Address of the SPL Token 2022 program - * - * @see https://solscan.io/account/TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb - */ -export const TOKEN_2022_PROGRAM_ID = new PublicKey( - "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" -); - -/** - * Address of the SPL Associated Token Account program - * - * (Creates Associated Token Accounts (ATA)) - * - * @see https://solscan.io/account/ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL - */ -export const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey( - "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" -); - /** * Storage of a token ATA * @@ -156,8 +135,6 @@ export const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey( */ const JUPITER_REFERRAL_ATA_ACCOUNT_SIZE_BYTES = 165; -const SPL_TOKEN_ATA_ACCOUNT_SIZE_BYTES = 165; - let debug: (context: string, message: string, ...args: any[]) => void; if (DEBUG) { debug = (context: string, message: string, ...args: any[]): void => { @@ -189,137 +166,6 @@ if (DEBUG) { // Jupiter API Tokens -/** - * curl -sL https://tokens.jup.ag/tokens?tags=verified | jq -C | less -N - */ -type JupiterTokenInfo = { - address: string; - name: string; - symbol: string; - decimals: string; -}; - -// Jupiter API Quote - -/** - * see https://station.jup.ag/api-v6/get-quote - * - * ```sh - * curl -sL -H 'Accept: application/json' 'https://quote-api.jup.ag/v6/quote?inputMint=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&outputMint=So11111111111111111111111111111111111111112&amount=5' - * ``` - */ -type JupiterQuoteResponse = { - /** @example "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" */ - inputMint: string; - /** @example "5" */ - inAmount: string; - /** @example "So11111111111111111111111111111111111111112" */ - outputMint: string; - /** @example "35" */ - outAmount: string; - /** @example "35" */ - otherAmountThreshold: string; - /** @example "ExactIn" */ - swapMode: string; - /** @example 50 */ - slippageBps: number; - /** @example {"amount":"1","feeBps":1} */ - platformFee: null | { - /** @example '1' */ - amount: string; - /** @example 1 */ - feeBps: number; - }; - /** @example "0" */ - priceImpactPct: string; - routePlan: { - swapInfo: { - /** @example "5URw47pYHN9heEQFKtUFeHzTskHwN78bBvKefV5C98fe" */ - ammKey: string; - /** @example "Oasis" */ - label: string; - /** @example "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" */ - inputMint: string; - /** @example "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" */ - outputMint: string; - /** @example "5" */ - inAmount: string; - /** @e xample "28679" */ - outAmount: string; - /** @exampl e "115" */ - feeAmount: string; - /** @example "DezXAZ8z7Pn rnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" */ - feeMint: string; - }; - /** @example 100 */ - percent: number; - }[]; - /** @example 284606533 */ - contextSlot: number; - /** @example 0.743937514 */ - timeTaken: number; -}; - -// Jupiter API Swap - -/** - * @see https://station.jup.ag/api-v6/post-swap - * - * HTTP request JSON body to request a Swap transaction for a given quote - */ -type JupiterSwapParams = { - userPublicKey: string; - /** Default: true */ - wrapAndUnwrapSol?: boolean; - useSharedAccounts?: boolean; - /** Referral address */ - feeAccount?: string; - /** Public key used to track transactions */ - trackingAccount?: string; - /** Integer */ - computeUnitPriceMicroLamports?: number; - prioritizationFeeLamports?: number; - /** Default: false */ - asLegacyTransaction?: boolean; - /** Default: false */ - useTokenLedger?: boolean; - /** Public key of key of token receiver. Default: user's own ATA. */ - destinationTokenAccount?: string; - /** - * Simulate the swap to get the compute units (like gas in the EVM) & - * set in ComputeBudget's compute unit limit. - * - * Default: false - */ - dynamicComputeUnitLimit?: boolean; - /** Do not do RPC calls to check on user's account. Default: false. */ - skipUserAccountRpcCalls?: boolean; - /** Response from the Jupiter API quote endpoint */ - quoteResponse: JupiterQuoteResponse; -}; - -/** - * @see https://station.jup.ag/api-v6/post-swap - */ -type JupiterSwapResponse = { - /** Base64 encoded versioned transaction */ - swapTransaction: string; - /** @example 265642441 */ - lastValidBlockHeight: number; - /** @example 99999 */ - prioritizationFeeLamports?: number; - /** @example 1400000 */ - computeUnitLimit?: number; - prioritizationType?: { - computeBudget?: { - microLamports: 71428; - estimatedMicroLamports: 142856; - }; - }; - dynamicSlippageReport: null; - simulationError: null; -}; - /** * Jupiter is a DEX on Solana * @@ -379,7 +225,6 @@ export class Jupiter extends ProviderClass { /** Intersection of token list & jupiter tokens */ this.toTokens[this.network] ??= {}; for (let i = 0, len = enkryptTokenList.length; i < len; i++) { - // TODO: handle native address const enkryptToken = enkryptTokenList[i]; let isTradeable = false; if (enkryptToken.address === NATIVE_TOKEN_ADDRESS) { @@ -399,7 +244,7 @@ export class Jupiter extends ProviderClass { ...enkryptToken, networkInfo: { name: SupportedNetworkName.Solana, - isAddress: isValidSolanaAddress, + isAddress: isValidSolanaAddressAsync, } satisfies TokenNetworkType, }; } @@ -434,8 +279,9 @@ export class Jupiter extends ProviderClass { const feeConf = FEE_CONFIGS[this.name][meta.walletIdentifier]; - if (!feeConf) + if (!feeConf) { throw new Error("Something went wrong: no fee config for Jupiter swap"); + } const referrerPubkey = new PublicKey(feeConf.referrer); @@ -692,6 +538,8 @@ export class Jupiter extends ProviderClass { to: quote.options.toAddress, serialized: base64SwapTransaction, type: TransactionType.solana, + kind: "versioned", + signed: false, }; debug( @@ -743,12 +591,10 @@ export class Jupiter extends ProviderClass { } if (txResponse.meta == null) { - // TODO: verify that `ConfirmedTransactionMeta` == null means pending return TransactionStatus.pending; } if (txResponse.meta.err != null) { - // TODO: verify that `err` != null means failed return TransactionStatus.failed; } @@ -756,39 +602,6 @@ export class Jupiter extends ProviderClass { } } -/** - * Is the address a valid Solana address? (32 byte base58 string) - * - * @param address hopefully 32 byte base58 string - * @returns true if `address` is a 32 byte base58 string - */ -function isValidSolanaAddress(address: string): Promise { - try { - new PublicKey(address); - return Promise.resolve(true); - } catch (err) { - return Promise.resolve(false); - } -} - -/** - * Does the Solana account exist? - * - * Checks if the account has been created - * - * @param conn Solana connection - * @param address Address to check - * @returns `true` if there's an account at `address` - */ -async function solAccountExists( - conn: Connection, - address: PublicKey -): Promise { - const account = await conn.getAccountInfo(address, "max"); - const exists = account != null; - return exists; -} - /** * Request all verified tokens available on Jupiter swap * @@ -1321,135 +1134,6 @@ function getJupiterReferrerAssociatedTokenAccount( return referrerATAPubkey; } -/** - * Construct a CreateAssociatedTokenAccountIdempotent instruction - * - * @param payer Payer of the initialization fees - * @param associatedToken New associated token account - * @param owner Owner of the new account - * @param mint Token mint account - * @param programId SPL Token program account - * @param associatedTokenProgramId SPL Associated Token program account - * - * @return Instruction to add to a transaction - */ -export function createAssociatedTokenAccountIdempotentInstruction( - payer: PublicKey, - associatedToken: PublicKey, - owner: PublicKey, - mint: PublicKey, - programId = TOKEN_PROGRAM_ID, - associatedTokenProgramId = ASSOCIATED_TOKEN_PROGRAM_ID -): TransactionInstruction { - return buildAssociatedTokenAccountInstruction( - payer, - associatedToken, - owner, - mint, - Buffer.from([1]), - programId, - associatedTokenProgramId - ); -} - -/** - * Create a transaction instruction that creates the given ATA for the owner and mint. - * - * Does nothing if the mint already exists. - * - * @see https://github.com/solana-labs/solana-program-library/blob/e018a30e751e759e62e17ad01864d4c57d090c26/token/js/src/instructions/associatedTokenAccount.ts#L49 - * @see https://github.com/solana-labs/solana-program-library/blob/e018a30e751e759e62e17ad01864d4c57d090c26/token/js/src/instructions/associatedTokenAccount.ts#L100 - */ -function getCreateAssociatedTokenAccountIdempotentInstruction(params: { - /** Payer of initialization / rent fees */ - payerPubkey: PublicKey; - /** Address of the Associated Token Account of `ownerPubkey` with `mintPubkey` @see `getSPLAssociatedTokenAccountPubkey` */ - ataPubkey: PublicKey; - /** Owner of the new SPL Associated Token Account */ - ownerPubkey: PublicKey; - /** - * SPL token address - * - * @example new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v') // USDC - * @example new PublicKey('Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB') // USDT - * @example new PublicKey('So11111111111111111111111111111111111111112') // Wrapped SOL - * - * USDC @see https://solscan.io/token/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v - * USDT @see https://solscan.io/token/Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB - * Wrapped SOL @see https://solscan.io/token/So11111111111111111111111111111111111111112 - */ - mintPubkey: PublicKey; - /** - * @example new PublicKey('11111111111111111111111111111111') - */ - systemProgramId: PublicKey; - /** - * SPL Token Program or 2022 SPL token program - * - * @example new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA') - * @example new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb') - */ - tokenProgramId: PublicKey; - /** - * SPL Associated Token Program account, - * - * @example new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL') - * - * @see https://solscan.io/account/ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL - */ - associatedTokenProgramId: PublicKey; -}): TransactionInstruction { - const { - payerPubkey, - ataPubkey, - ownerPubkey, - mintPubkey, - systemProgramId, - tokenProgramId, - associatedTokenProgramId, - } = params; - - const keys = [ - { pubkey: payerPubkey, isSigner: true, isWritable: true }, - { pubkey: ataPubkey, isSigner: false, isWritable: true }, - { pubkey: ownerPubkey, isSigner: false, isWritable: false }, - { pubkey: mintPubkey, isSigner: false, isWritable: false }, - { pubkey: systemProgramId, isSigner: false, isWritable: false }, - { pubkey: tokenProgramId, isSigner: false, isWritable: false }, - ]; - - return new TransactionInstruction({ - keys, - programId: associatedTokenProgramId, - data: Buffer.from([1]), - }); -} - -function buildAssociatedTokenAccountInstruction( - payer: PublicKey, - associatedToken: PublicKey, - owner: PublicKey, - mint: PublicKey, - instructionData: Buffer, - programId = TOKEN_PROGRAM_ID, - associatedTokenProgramId = ASSOCIATED_TOKEN_PROGRAM_ID -): TransactionInstruction { - const keys = [ - { pubkey: payer, isSigner: true, isWritable: true }, - { pubkey: associatedToken, isSigner: false, isWritable: true }, - { pubkey: owner, isSigner: false, isWritable: false }, - { pubkey: mint, isSigner: false, isWritable: false }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: programId, isSigner: false, isWritable: false }, - ]; - - return new TransactionInstruction({ - keys, - programId: associatedTokenProgramId, - data: instructionData, - }); -} - /** * Links: * - [Jupiter Referral GitHub](https://github.com/TeamRaccoons/referral) @@ -1538,151 +1222,3 @@ function getJupiterInitialiseReferralTokenAccountInstruction(params: { return instruction; } - -/** - * Insert new instructions at the start of a transaction, after compute budget and compute limit instructions - */ -async function insertInstructionsAtStartOfTransaction( - conn: Connection, - tx: VersionedTransaction, - instructions: TransactionInstruction[] -): Promise { - if (instructions.length === 0) return tx; - - // Now we need to: - // 1. Decompile the transaction - // 2. Put our instructions in it - // 3. Recompile it - - // Request lookup accounts so that we can decompile the message - // - // Lookup accounts store arrays of addresses. These accounts let compiled transaction messages reference indexes - // in the lookup account rather than by the pubkey, saving lots of space (~4 byte integer index vs 32 byte pubkey). - // - // To decompile a message we first need all the lookup accounts that it includes so that we can get the - // the addresses that our message needs. - // - // We can also use the lookup accounts when re-compiling the transaction. - const lookupAccountsCount = tx.message.addressTableLookups.length; - const addressLookupTableAccounts: AddressLookupTableAccount[] = new Array( - lookupAccountsCount - ); - - for (let i = 0; i < lookupAccountsCount; i++) { - const lookup = tx.message.addressTableLookups[i]; - const result = await conn.getAddressLookupTable(lookup.accountKey); - const addressLookupTableAccount = result.value; - if (addressLookupTableAccount == null) - throw new Error( - `Failed to get address lookup table for ${lookup.accountKey}` - ); - debug( - "insertInstructionsAtStartOfTransaction", - `Fetching lookup account ${i + 1}. ${lookup.accountKey.toBase58()}` - ); - addressLookupTableAccounts[i] = addressLookupTableAccount; - } - - // Decompile the transaction message so we can modify it - const decompiledTransactionMessage = TransactionMessage.decompile( - tx.message, - { addressLookupTableAccounts } - ); - - // Insert our instruction to create an account directly after compute budget - // program instructions that compute limits and priority fees - const computeBudgetProgramAddr = ComputeBudgetProgram.programId.toBase58(); - let inserted = false; - instructionLoop: for ( - let i = 0, len = decompiledTransactionMessage.instructions.length; - i < len; - i++ - ) { - // As soon as we hit a non compute budget program, insert our instruction to create the account - const existingInstruction = decompiledTransactionMessage.instructions[i]; - switch (existingInstruction.programId.toBase58()) { - case computeBudgetProgramAddr: - // do nothing - break; - default: { - // insert our instruction here & continue - debug( - "insertInstructionsAtStartOfTransaction", - `Inserting instruction to create an ATA account for Jupiter referrer with mint at instruction index ${i}` - ); - inserted = true; - decompiledTransactionMessage.instructions.splice(i, 0, ...instructions); - break instructionLoop; - } - } - } - - if (!inserted) { - // If there were no compute budget instructions then just add it at the start - debug( - "insertInstructionsAtStartOfTransaction", - `Inserting instruction to create an ATA account for Jupiter referrer with mint at start of instructions` - ); - for (let len = instructions.length - 1, i = len - 1; i >= 0; i--) { - decompiledTransactionMessage.instructions.unshift(instructions[i]); - } - } - - // Switch to using this modified transaction - debug("insertInstructionsAtStartOfTransaction", `Re-compiling transaction`); - const modifiedTx = new VersionedTransaction( - decompiledTransactionMessage.compileToV0Message(addressLookupTableAccounts) - ); - - return modifiedTx; -} - -/** - * Get the SPL token program that owns (/created) the given mint (token). Either the SPL token program - * or the 2022 SPL token program - * - * @returns Pubkey of the SPL token token owner program - * @throws If the account does not exist or if it's not owned by one of the SPL token programs - */ -async function getTokenProgramOfMint( - conn: Connection, - mint: PublicKey -): Promise { - debug("getTokenProgramOfMint", `Checking mint account of ${mint.toBase58()}`); - const srcMintAcc = await conn.getAccountInfo(mint); - - if (srcMintAcc == null) { - throw new Error( - `There is no SPL token account at address ${mint.toBase58()}` - ); - } - - switch (srcMintAcc.owner.toBase58()) { - case TOKEN_PROGRAM_ID.toBase58(): - case TOKEN_2022_PROGRAM_ID.toBase58(): - return srcMintAcc.owner; - default: - throw new Error( - `Mint address is not a valid SPL token, must either have owner` + - ` TOKEN_PROGRAM_ID (${TOKEN_PROGRAM_ID.toBase58()})` + - ` or TOKEN_2022_PROGRAM_ID (${TOKEN_2022_PROGRAM_ID.toBase58()})` - ); - } -} - -/** - * Get the SPL token ATA pubkey for a wallet with a mint - */ -function getSPLAssociatedTokenAccountPubkey( - wallet: PublicKey, - mint: PublicKey, - /** Either the SPL token program or the 2022 SPL token program */ - tokenProgramId: PublicKey -): PublicKey { - const SEED = [wallet.toBuffer(), tokenProgramId.toBuffer(), mint.toBuffer()]; - const [associatedTokenAddress] = PublicKey.findProgramAddressSync( - SEED, - ASSOCIATED_TOKEN_PROGRAM_ID - ); - return associatedTokenAddress; -} diff --git a/packages/swap/src/providers/jupiter/types.ts b/packages/swap/src/providers/jupiter/types.ts new file mode 100644 index 000000000..6848441fb --- /dev/null +++ b/packages/swap/src/providers/jupiter/types.ts @@ -0,0 +1,130 @@ +/** + * curl -sL https://tokens.jup.ag/tokens?tags=verified | jq -C | less -N + */ +export type JupiterTokenInfo = { + address: string; + name: string; + symbol: string; + decimals: string; +}; + +// Jupiter API Quote + +/** + * see https://station.jup.ag/api-v6/get-quote + * + * ```sh + * curl -sL -H 'Accept: application/json' 'https://quote-api.jup.ag/v6/quote?inputMint=EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v&outputMint=So11111111111111111111111111111111111111112&amount=5' + * ``` + */ +export type JupiterQuoteResponse = { + /** @example "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" */ + inputMint: string; + /** @example "5" */ + inAmount: string; + /** @example "So11111111111111111111111111111111111111112" */ + outputMint: string; + /** @example "35" */ + outAmount: string; + /** @example "35" */ + otherAmountThreshold: string; + /** @example "ExactIn" */ + swapMode: string; + /** @example 50 */ + slippageBps: number; + /** @example {"amount":"1","feeBps":1} */ + platformFee: null | { + /** @example '1' */ + amount: string; + /** @example 1 */ + feeBps: number; + }; + /** @example "0" */ + priceImpactPct: string; + routePlan: { + swapInfo: { + /** @example "5URw47pYHN9heEQFKtUFeHzTskHwN78bBvKefV5C98fe" */ + ammKey: string; + /** @example "Oasis" */ + label: string; + /** @example "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" */ + inputMint: string; + /** @example "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" */ + outputMint: string; + /** @example "5" */ + inAmount: string; + /** @e xample "28679" */ + outAmount: string; + /** @exampl e "115" */ + feeAmount: string; + /** @example "DezXAZ8z7Pn rnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" */ + feeMint: string; + }; + /** @example 100 */ + percent: number; + }[]; + /** @example 284606533 */ + contextSlot: number; + /** @example 0.743937514 */ + timeTaken: number; +}; + +// Jupiter API Swap + +/** + * @see https://station.jup.ag/api-v6/post-swap + * + * HTTP request JSON body to request a Swap transaction for a given quote + */ +export type JupiterSwapParams = { + userPublicKey: string; + /** Default: true */ + wrapAndUnwrapSol?: boolean; + useSharedAccounts?: boolean; + /** Referral address */ + feeAccount?: string; + /** Public key used to track transactions */ + trackingAccount?: string; + /** Integer */ + computeUnitPriceMicroLamports?: number; + prioritizationFeeLamports?: number; + /** Default: false */ + asLegacyTransaction?: boolean; + /** Default: false */ + useTokenLedger?: boolean; + /** Public key of key of token receiver. Default: user's own ATA. */ + destinationTokenAccount?: string; + /** + * Simulate the swap to get the compute units (like gas in the EVM) & + * set in ComputeBudget's compute unit limit. + * + * Default: false + */ + dynamicComputeUnitLimit?: boolean; + /** Do not do RPC calls to check on user's account. Default: false. */ + skipUserAccountRpcCalls?: boolean; + /** Response from the Jupiter API quote endpoint */ + quoteResponse: JupiterQuoteResponse; +}; + +/** + * @see https://station.jup.ag/api-v6/post-swap + */ +export type JupiterSwapResponse = { + /** Base64 encoded versioned transaction */ + swapTransaction: string; + /** @example 265642441 */ + lastValidBlockHeight: number; + /** @example 99999 */ + prioritizationFeeLamports?: number; + /** @example 1400000 */ + computeUnitLimit?: number; + prioritizationType?: { + computeBudget?: { + microLamports: 71428; + estimatedMicroLamports: 142856; + }; + }; + dynamicSlippageReport: null; + simulationError: null; +}; diff --git a/packages/swap/src/providers/rango/index.ts b/packages/swap/src/providers/rango/index.ts index 69cb67244..95f9119d3 100644 --- a/packages/swap/src/providers/rango/index.ts +++ b/packages/swap/src/providers/rango/index.ts @@ -10,8 +10,16 @@ import { RoutingResultType, TransactionType as RangoTransactionType, } from "rango-sdk-basic"; -import { Connection, VersionedTransaction } from "@solana/web3.js"; -import { extractComputeBudget } from "../../utils/solana"; +import { + Connection, + PublicKey, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, + Transaction as SolanaLegacyTransaction, +} from "@solana/web3.js"; +import bs58 from "bs58"; +import { extractComputeBudget, isValidSolanaAddress } from "../../utils/solana"; import { EVMTransaction, getQuoteOptions, @@ -24,6 +32,7 @@ import { ProviderSwapResponse, ProviderToTokenResponse, QuoteMetaOptions, + SolanaTransaction, StatusOptions, StatusOptionsResponse, SupportedNetworkName, @@ -43,11 +52,12 @@ import { TOKEN_AMOUNT_INFINITY_AND_BEYOND } from "../../utils/approvals"; import estimateEVMGasList from "../../common/estimateGasList"; import { isEVMAddress } from "../../utils/common"; +/** Enables debug logging in this file */ +const DEBUG = false; + const RANGO_PUBLIC_API_KEY = "ee7da377-0ed8-4d42-aaf9-fa978a32b18d"; const rangoClient = new RangoClient(RANGO_PUBLIC_API_KEY); -const DEBUG = false; - let debug: (context: string, message: string, ...args: any[]) => void; if (DEBUG) { debug = (context: string, message: string, ...args: any[]): void => { @@ -260,13 +270,16 @@ class Rango extends ProviderClass { rangoToNetwork[supportedNetworks[net].name] = net as unknown as SupportedNetworkName; }); + tokens?.forEach((t) => { + // Unrecognised network if (!supportedCRangoNames.includes(t.blockchain)) return; - if (!this.toTokens[rangoToNetwork[t.blockchain]]) - this.toTokens[rangoToNetwork[t.blockchain]] = {}; - this.toTokens[rangoToNetwork[t.blockchain]][ - t.address || NATIVE_TOKEN_ADDRESS - ] = { + + const network = rangoToNetwork[t.blockchain]; + + this.toTokens[network] ??= {}; + + this.toTokens[network][t.address || NATIVE_TOKEN_ADDRESS] = { ...t, name: t.name || t.symbol, logoURI: t.image, @@ -274,8 +287,8 @@ class Rango extends ProviderClass { price: t.usdPrice, networkInfo: { name: rangoToNetwork[t.blockchain], - isAddress: (address: string) => - Promise.resolve(isEVMAddress(address)), + // eslint-disable-next-line no-use-before-define + isAddress: getIsAddressAsync(network), }, }; }); @@ -478,59 +491,6 @@ class Rango extends ProviderClass { let networkTransactions: RangoNetworkedTransactions; switch (rangoSwapResponse.tx?.type) { - // Process Rango swap Solana transaction - case RangoTransactionType.SOLANA: { - debug("getRangoSwap", "Received Solana transaction"); - - let versionedTransaction: VersionedTransaction; - if (rangoSwapResponse.tx.serializedMessage == null) { - // TODO: When how and why does this happen? - debug( - "getRangoSwap", - "Dropping rango swap transaction: Rango SDK returned a Solana transaction without any serializedMessage" - ); - return null; - } - switch (rangoSwapResponse.tx.txType) { - case "VERSIONED": { - debug("getRangoSwap", `Deserializing Solana versioned transaction`); - versionedTransaction = VersionedTransaction.deserialize( - new Uint8Array(rangoSwapResponse.tx.serializedMessage) - ); - break; - } - case "LEGACY": { - debug("getRangoSwap", `Deserializing Solana legacy transaction`); - // TODO: does this work? versionedTransaction.version has type `'legacy' | 0` so maybe? - versionedTransaction = VersionedTransaction.deserialize( - new Uint8Array(rangoSwapResponse.tx.serializedMessage) - ); - break; - } - default: - rangoSwapResponse.tx.txType satisfies never; - throw new Error( - `Unhandled Rango Solana transaction type: ${rangoSwapResponse.tx.txType}` - ); - } - - networkTransactions = { - type: NetworkType.Solana, - transactions: [ - { - type: TransactionType.solana, - from: rangoSwapResponse.tx.from, - // TODO: is this right? - to: options.toToken.address, - serialized: Buffer.from( - versionedTransaction.serialize() - ).toString("base64"), - }, - ], - }; - break; - } - // Process Rango swap EVM transaction case RangoTransactionType.EVM: { debug("getRangoSwap", `Received EVM transaction`); @@ -580,6 +540,277 @@ class Rango extends ProviderClass { break; } + // Process Rango swap Solana transaction + case RangoTransactionType.SOLANA: { + debug("getRangoSwap", "Received Solana transaction"); + + let enkSolTx: SolanaTransaction; + + switch (rangoSwapResponse.tx.txType) { + case "VERSIONED": { + if (rangoSwapResponse.tx.serializedMessage) { + debug( + "getRangoSwap", + `Deserializing Solana versioned unsigned transaction` + ); + + // Versioned transaction, not signed (we can modify it) + // > When serialized message appears, there is no need for other fields and you just sign and send it + // @see (2024-09-17) https://docs.rango.exchange/api-integration/main-api-multi-step/sample-transactions#solana-sample-transaction-test + + // Sanity checks + if (rangoSwapResponse.tx.instructions.length) { + console.warn( + "Expected Rango Solana unsigned versioned transaction NOT to have instructions but instructions array is not empty", + { ...rangoSwapResponse.tx } + ); + return null; + } + if (rangoSwapResponse.tx.signatures.length) { + console.warn( + "Expected Rango Solana unsigned versioned transaction NOT to have signatures but signatures array is not empty", + { ...rangoSwapResponse.tx } + ); + return null; + } + if (rangoSwapResponse.tx.recentBlockhash) { + console.warn( + "Expected Rango Solana unsigned versioned transaction NOT to have a recent blockhash but recentBlockhash is defined", + { ...rangoSwapResponse.tx } + ); + return null; + } + + const vtx = VersionedTransaction.deserialize( + new Uint8Array(rangoSwapResponse.tx.serializedMessage) + ); + + enkSolTx = { + type: TransactionType.solana, + from: rangoSwapResponse.tx.from, + to: options.toToken.address, + kind: "versioned", + signed: false, + serialized: Buffer.from(vtx.serialize()).toString("base64"), + }; + } else { + debug( + "getRangoSwap", + `Deserializing Solana versioned signed transaction` + ); + + // Versioned transaction signed by Rango + // We are unable to alter this transaction + // Since the recent block hash gets signed too, this transaction will need to be consumed quickly + + // Sanity checks + if (!rangoSwapResponse.tx.instructions.length) { + console.warn( + "Expected Rango Solana signed versioned transaction to have instructions but instructions array is empty", + { ...rangoSwapResponse.tx } + ); + return null; + } + if (!rangoSwapResponse.tx.signatures.length) { + console.warn( + "Expected Rango Solana signed versioned transaction to have signatures but signatures array is empty", + { ...rangoSwapResponse.tx } + ); + return null; + } + if (!rangoSwapResponse.tx.recentBlockhash) { + console.warn( + "Expected Rango Solana signed versioned transaction to have a recent blockhash but recentBlockhash is not defined", + { ...rangoSwapResponse.tx } + ); + return null; + } + + const vtx = new VersionedTransaction( + new TransactionMessage({ + instructions: rangoSwapResponse.tx.instructions.map( + (instr) => + new TransactionInstruction({ + keys: instr.keys.map( + ({ pubkey, isSigner, isWritable }) => ({ + pubkey: new PublicKey(pubkey), + isSigner, + isWritable, + }) + ), + // For some reason Rango returns instruction data as signed 8 bit array + // so we convert to unsigned + data: Buffer.from( + instr.data.map((int8) => (int8 + 256) % 256) + ), + programId: new PublicKey(instr.programId), + }) + ), + recentBlockhash: rangoSwapResponse.tx.recentBlockhash, + payerKey: new PublicKey(options.fromAddress), + }).compileToV0Message(), + rangoSwapResponse.tx.signatures.map( + // For some reason Rango returns signatures as a signed 8 bit array + // so we convert to unsigned + ({ signature }) => + new Uint8Array(signature.map((int8) => (int8 + 256) % 256)) + ) + ); + + enkSolTx = { + type: TransactionType.solana, + from: rangoSwapResponse.tx.from, + to: options.toToken.address, + kind: "versioned", + signed: true, + serialized: Buffer.from(vtx.serialize()).toString("base64"), + }; + } + break; + } + case "LEGACY": { + if (rangoSwapResponse.tx.serializedMessage) { + debug( + "getRangoSwap", + `Deserializing Solana legacy unsigned transaction` + ); + + // Legacy transaction, not signed (we can modify it) + // > When serialized message appears, there is no need for other fields and you just sign and send it + // @see (2024-09-17) https://docs.rango.exchange/api-integration/main-api-multi-step/sample-transactions#solana-sample-transaction-test + + // Sanity checks + if (rangoSwapResponse.tx.instructions.length) { + console.warn( + "Expected Rango Solana unsigned legacy transaction NOT to have instructions but instructions array is not empty", + { ...rangoSwapResponse.tx } + ); + return null; + } + if (rangoSwapResponse.tx.signatures.length) { + console.warn( + "Expected Rango Solana unsigned legacy transaction NOT to have signatures but signatures array is not empty", + { ...rangoSwapResponse.tx } + ); + return null; + } + if (rangoSwapResponse.tx.recentBlockhash) { + console.warn( + "Expected Rango Solana unsigned legacy transaction NOT to have a recent blockhash but recentBlockhash is defined", + { ...rangoSwapResponse.tx } + ); + return null; + } + const ltx = SolanaLegacyTransaction.from( + rangoSwapResponse.tx.serializedMessage + ); + + enkSolTx = { + type: TransactionType.solana, + from: rangoSwapResponse.tx.from, + to: options.toToken.address, + kind: "legacy", + signed: false, + serialized: ltx.serialize().toString("base64"), + }; + } else { + debug( + "getRangoSwap", + `Deserializing Solana legacy signed transaction` + ); + + // Legacy transaction signed by Rango + // We are unable to alter this transaction + // Since the recent block hash gets signed too, this transaction will need to be consumed quickly + + // Sanity checks + if (!rangoSwapResponse.tx.instructions.length) { + console.warn( + "Expected Rango Solana signed legacy transaction to have instructions but instructions array is empty", + { ...rangoSwapResponse.tx } + ); + return null; + } + if (!rangoSwapResponse.tx.signatures.length) { + console.warn( + "Expected Rango Solana signed legacy transaction to have signatures but signatures array is empty", + { ...rangoSwapResponse.tx } + ); + return null; + } + if (!rangoSwapResponse.tx.recentBlockhash) { + console.warn( + "Expected Rango Solana signed legacy transaction to have a recent blockhash but recentBlockhash is not defined", + { ...rangoSwapResponse.tx } + ); + return null; + } + + console.log("===== 1"); + console.log({ ...rangoSwapResponse }); + const ltx = SolanaLegacyTransaction.populate( + new TransactionMessage({ + instructions: rangoSwapResponse.tx.instructions.map( + (instr) => + new TransactionInstruction({ + keys: instr.keys.map( + ({ pubkey, isSigner, isWritable }) => ({ + pubkey: new PublicKey(pubkey), + isSigner, + isWritable, + }) + ), + // For some reason Rango returns instruction data as signed 8 bit array + // so we convert to unsigned + data: Buffer.from( + instr.data.map((int8) => (int8 + 256) % 256) + ), + programId: new PublicKey(instr.programId), + }) + ), + recentBlockhash: rangoSwapResponse.tx.recentBlockhash, + payerKey: new PublicKey(options.fromAddress), + }).compileToLegacyMessage(), + rangoSwapResponse.tx.signatures.map(({ signature }) => + // For some reason Rango returns signatures as a signed 8 bit array + // so we convert to unsigned + bs58.encode( + Buffer.from(signature.map((int8) => (int8 + 256) % 256)) + ) + ) + ); + console.log("===== 2"); + + enkSolTx = { + type: TransactionType.solana, + from: rangoSwapResponse.tx.from, + to: options.toToken.address, + kind: "legacy", + signed: true, + // We'll provide our own signature later + serialized: ltx + .serialize({ requireAllSignatures: false }) + .toString("base64"), + }; + + console.log("===== 3"); + } + break; + } + default: + rangoSwapResponse.tx.txType satisfies never; + throw new Error( + `Unhandled Rango Solana transaction type: ${rangoSwapResponse.tx.txType}` + ); + } + + networkTransactions = { + type: NetworkType.Solana, + transactions: [enkSolTx], + }; + break; + } + case undefined: case null: { throw new Error(`Rango did not return a transaction type`); @@ -791,4 +1022,22 @@ class Rango extends ProviderClass { } } +async function isEVMAddressAsync(address: string): Promise { + return isEVMAddress(address); +} +async function isSolanaAddressAsync(address: string): Promise { + return isValidSolanaAddress(address); +} + +function getIsAddressAsync( + network: SupportedNetworkName +): (address: string) => Promise { + switch (network) { + case SupportedNetworkName.Solana: + return isSolanaAddressAsync; + default: + return isEVMAddressAsync; + } +} + export default Rango; diff --git a/packages/swap/src/types/index.ts b/packages/swap/src/types/index.ts index 8f2a71a76..69ece51a1 100644 --- a/packages/swap/src/types/index.ts +++ b/packages/swap/src/types/index.ts @@ -165,8 +165,11 @@ export interface SolanaTransaction { from: string; /** TODO: document what this is for, I think it's just for UI */ to: string; + kind: "legacy" | "versioned"; /** base64 serialized unsigned solana transaction */ serialized: string; + /** If the transaction is signed (by a third party like Rango) then we can't change it's recentblock hash */ + signed: boolean; type: TransactionType.solana; } diff --git a/packages/swap/src/utils/solana.ts b/packages/swap/src/utils/solana.ts index 0dc8d6b7c..183924da0 100644 --- a/packages/swap/src/utils/solana.ts +++ b/packages/swap/src/utils/solana.ts @@ -1,11 +1,54 @@ import { AccountMeta, + AddressLookupTableAccount, ComputeBudgetInstruction, ComputeBudgetProgram, + Connection, + PublicKey, + SystemProgram, TransactionInstruction, + TransactionMessage, VersionedTransaction, } from "@solana/web3.js"; +/** + * Address of the SPL Token program + * + * @see https://solscan.io/account/TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA + */ +export const TOKEN_PROGRAM_ID = new PublicKey( + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" +); + +/** + * Address of the SPL Token 2022 program + * + * @see https://solscan.io/account/TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb + */ +export const TOKEN_2022_PROGRAM_ID = new PublicKey( + "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" +); + +/** + * Address of the SPL Associated Token Account program + * + * (Creates Associated Token Accounts (ATA)) + * + * @see https://solscan.io/account/ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL + */ +export const ASSOCIATED_TOKEN_PROGRAM_ID = new PublicKey( + "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" +); + +export const SPL_TOKEN_ATA_ACCOUNT_SIZE_BYTES = 165; + +/** + * Wrapped SOL address + * @see https://solscan.io/token/So11111111111111111111111111111111111111112 + */ +export const WRAPPED_SOL_ADDRESS = + "So11111111111111111111111111111111111111112"; + /** * @see https://solana.com/docs/core/fees#prioritization-fees * @@ -18,6 +61,8 @@ export function extractComputeBudget( tx: VersionedTransaction ): undefined | number { // extract the compute budget + + /** Compute units */ let computeBudget: undefined | number; // eslint-disable-next-line no-restricted-syntax, no-labels @@ -62,6 +107,333 @@ export function extractComputeBudget( } } - // (default of 200_000 from Google) - return computeBudget ?? 200_000; + /** + * @see https://solanacookbook.com/references/basic-transactions.html#how-to-change-compute-budget-fee-priority-for-a-transaction + * + * Default is minimum of 14m and 200_000 * instruction count + */ + return ( + computeBudget ?? + Math.min(1_400_000, 200_000 * tx.message.compiledInstructions.length) + ); +} + +/** + * Insert new instructions at the start of a transaction, after compute budget and compute limit instructions + */ +export async function insertInstructionsAtStartOfTransaction( + conn: Connection, + tx: VersionedTransaction, + instructions: TransactionInstruction[] +): Promise { + if (instructions.length === 0) return tx; + + // Now we need to: + // 1. Decompile the transaction + // 2. Put our instructions in it + // 3. Recompile it + + // Request lookup accounts so that we can decompile the message + // + // Lookup accounts store arrays of addresses. These accounts let compiled transaction messages reference indexes + // in the lookup account rather than by the pubkey, saving lots of space (~4 byte integer index vs 32 byte pubkey). + // + // To decompile a message we first need all the lookup accounts that it includes so that we can get the + // the addresses that our message needs. + // + // We can also use the lookup accounts when re-compiling the transaction. + const lookupAccountsCount = tx.message.addressTableLookups.length; + const addressLookupTableAccounts: AddressLookupTableAccount[] = new Array( + lookupAccountsCount + ); + + for (let i = 0; i < lookupAccountsCount; i++) { + const lookup = tx.message.addressTableLookups[i]; + const result = await conn.getAddressLookupTable(lookup.accountKey); + const addressLookupTableAccount = result.value; + if (addressLookupTableAccount == null) + throw new Error( + `Failed to get address lookup table for ${lookup.accountKey}` + ); + // debug( + // "insertInstructionsAtStartOfTransaction", + // `Fetching lookup account ${i + 1}. ${lookup.accountKey.toBase58()}` + // ); + addressLookupTableAccounts[i] = addressLookupTableAccount; + } + + // Decompile the transaction message so we can modify it + const decompiledTransactionMessage = TransactionMessage.decompile( + tx.message, + { addressLookupTableAccounts } + ); + + // Insert our instruction to create an account directly after compute budget + // program instructions that compute limits and priority fees + const computeBudgetProgramAddr = ComputeBudgetProgram.programId.toBase58(); + let inserted = false; + // eslint-disable-next-line no-restricted-syntax, no-labels + instructionLoop: for ( + let i = 0, len = decompiledTransactionMessage.instructions.length; + i < len; + i++ + ) { + // As soon as we hit a non compute budget program, insert our instruction to create the account + const existingInstruction = decompiledTransactionMessage.instructions[i]; + switch (existingInstruction.programId.toBase58()) { + case computeBudgetProgramAddr: + // do nothing + break; + default: { + // insert our instruction here & continue + // debug( + // "insertInstructionsAtStartOfTransaction", + // `Inserting instruction to create an ATA account for Jupiter referrer with mint at instruction index ${i}` + // ); + inserted = true; + decompiledTransactionMessage.instructions.splice(i, 0, ...instructions); + // eslint-disable-next-line no-labels + break instructionLoop; + } + } + } + + if (!inserted) { + // If there were no compute budget instructions then just add it at the start + // debug( + // "insertInstructionsAtStartOfTransaction", + // `Inserting instruction to create an ATA account for Jupiter referrer with mint at start of instructions` + // ); + for (let len = instructions.length - 1, i = len - 1; i >= 0; i--) { + decompiledTransactionMessage.instructions.unshift(instructions[i]); + } + } + + // Switch to using this modified transaction + // debug("insertInstructionsAtStartOfTransaction", `Re-compiling transaction`); + const modifiedTx = new VersionedTransaction( + decompiledTransactionMessage.compileToV0Message(addressLookupTableAccounts) + ); + + return modifiedTx; +} + +/** + * Get the SPL token program that owns (/created) the given mint (token). Either the SPL token program + * or the 2022 SPL token program + * + * @returns Pubkey of the SPL token token owner program + * @throws If the account does not exist or if it's not owned by one of the SPL token programs + */ +export async function getTokenProgramOfMint( + conn: Connection, + mint: PublicKey +): Promise { + // debug("getTokenProgramOfMint", `Checking mint account of ${mint.toBase58()}`); + const srcMintAcc = await conn.getAccountInfo(mint); + + if (srcMintAcc == null) { + throw new Error( + `There is no SPL token account at address ${mint.toBase58()}` + ); + } + + switch (srcMintAcc.owner.toBase58()) { + case TOKEN_PROGRAM_ID.toBase58(): + case TOKEN_2022_PROGRAM_ID.toBase58(): + return srcMintAcc.owner; + default: + throw new Error( + `Mint address is not a valid SPL token, must either have owner` + + ` TOKEN_PROGRAM_ID (${TOKEN_PROGRAM_ID.toBase58()})` + + ` or TOKEN_2022_PROGRAM_ID (${TOKEN_2022_PROGRAM_ID.toBase58()})` + ); + } +} + +/** + * Get the SPL token ATA pubkey for a wallet with a mint + */ +export function getSPLAssociatedTokenAccountPubkey( + wallet: PublicKey, + mint: PublicKey, + /** Either the SPL token program or the 2022 SPL token program */ + tokenProgramId: PublicKey +): PublicKey { + const SEED = [wallet.toBuffer(), tokenProgramId.toBuffer(), mint.toBuffer()]; + const [associatedTokenAddress] = PublicKey.findProgramAddressSync( + SEED, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + return associatedTokenAddress; +} + +/** + * Construct a CreateAssociatedTokenAccountIdempotent instruction + * + * @param payer Payer of the initialization fees + * @param associatedToken New associated token account + * @param owner Owner of the new account + * @param mint Token mint account + * @param programId SPL Token program account + * @param associatedTokenProgramId SPL Associated Token program account + * + * @return Instruction to add to a transaction + */ +export function createAssociatedTokenAccountIdempotentInstruction( + payer: PublicKey, + associatedToken: PublicKey, + owner: PublicKey, + mint: PublicKey, + programId = TOKEN_PROGRAM_ID, + associatedTokenProgramId = ASSOCIATED_TOKEN_PROGRAM_ID +): TransactionInstruction { + // eslint-disable-next-line no-use-before-define + return buildAssociatedTokenAccountInstruction( + payer, + associatedToken, + owner, + mint, + Buffer.from([1]), + programId, + associatedTokenProgramId + ); +} + +export function buildAssociatedTokenAccountInstruction( + payer: PublicKey, + associatedToken: PublicKey, + owner: PublicKey, + mint: PublicKey, + instructionData: Buffer, + programId = TOKEN_PROGRAM_ID, + associatedTokenProgramId = ASSOCIATED_TOKEN_PROGRAM_ID +): TransactionInstruction { + const keys = [ + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: associatedToken, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: programId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + keys, + programId: associatedTokenProgramId, + data: instructionData, + }); +} + +/** + * Create a transaction instruction that creates the given ATA for the owner and mint. + * + * Does nothing if the mint already exists. + * + * @see https://github.com/solana-labs/solana-program-library/blob/e018a30e751e759e62e17ad01864d4c57d090c26/token/js/src/instructions/associatedTokenAccount.ts#L49 + * @see https://github.com/solana-labs/solana-program-library/blob/e018a30e751e759e62e17ad01864d4c57d090c26/token/js/src/instructions/associatedTokenAccount.ts#L100 + */ +export function getCreateAssociatedTokenAccountIdempotentInstruction(params: { + /** Payer of initialization / rent fees */ + payerPubkey: PublicKey; + /** Address of the Associated Token Account of `ownerPubkey` with `mintPubkey` @see `getSPLAssociatedTokenAccountPubkey` */ + ataPubkey: PublicKey; + /** Owner of the new SPL Associated Token Account */ + ownerPubkey: PublicKey; + /** + * SPL token address + * + * @example new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v') // USDC + * @example new PublicKey('Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB') // USDT + * @example new PublicKey('So11111111111111111111111111111111111111112') // Wrapped SOL + * + * USDC @see https://solscan.io/token/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v + * USDT @see https://solscan.io/token/Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB + * Wrapped SOL @see https://solscan.io/token/So11111111111111111111111111111111111111112 + */ + mintPubkey: PublicKey; + /** + * @example new PublicKey('11111111111111111111111111111111') + */ + systemProgramId: PublicKey; + /** + * SPL Token Program or 2022 SPL token program + * + * @example new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA') + * @example new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb') + */ + tokenProgramId: PublicKey; + /** + * SPL Associated Token Program account, + * + * @example new PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL') + * + * @see https://solscan.io/account/ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL + */ + associatedTokenProgramId: PublicKey; +}): TransactionInstruction { + const { + payerPubkey, + ataPubkey, + ownerPubkey, + mintPubkey, + systemProgramId, + tokenProgramId, + associatedTokenProgramId, + } = params; + + const keys = [ + { pubkey: payerPubkey, isSigner: true, isWritable: true }, + { pubkey: ataPubkey, isSigner: false, isWritable: true }, + { pubkey: ownerPubkey, isSigner: false, isWritable: false }, + { pubkey: mintPubkey, isSigner: false, isWritable: false }, + { pubkey: systemProgramId, isSigner: false, isWritable: false }, + { pubkey: tokenProgramId, isSigner: false, isWritable: false }, + ]; + + return new TransactionInstruction({ + keys, + programId: associatedTokenProgramId, + data: Buffer.from([1]), + }); +} + +/** + * Is the address a valid Solana address? (32 byte base58 string) + * + * @param address hopefully 32 byte base58 string + * @returns true if `address` is a 32 byte base58 string + */ +export function isValidSolanaAddress(address: string): boolean { + try { + // eslint-disable-next-line no-new + new PublicKey(address); + return true; + } catch (err) { + return false; + } +} + +export async function isValidSolanaAddressAsync( + address: string +): Promise { + return isValidSolanaAddress(address); +} + +/** + * Does the Solana account exist? + * + * Checks if the account has been created + * + * @param conn Solana connection + * @param address Address to check + * @returns `true` if there's an account at `address` + */ +export async function solAccountExists( + conn: Connection, + address: PublicKey +): Promise { + const account = await conn.getAccountInfo(address, "max"); + const exists = account != null; + return exists; } diff --git a/yarn.lock b/yarn.lock index 72cdbd5c1..764f967ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3286,6 +3286,7 @@ __metadata: dependencies: "@enkryptcom/types": "workspace:^" "@enkryptcom/utils": "workspace:^" + "@solana/spl-token": ^0.4.8 "@solana/web3.js": ^1.95.3 "@types/chai": ^4.3.19 "@types/mocha": ^10.0.7