diff --git a/.gitignore b/.gitignore index 485dee6..0a4693d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -.idea +.idea/** +sdk/*.tgz +node_modules/ \ No newline at end of file diff --git a/sdk/package.json b/sdk/package.json index 18220d8..c4c0b29 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,5 +1,5 @@ { - "version": "0.18.0", + "version": "0.19.0", "name": "@rainbow-me/swaps", "license": "GPL-3.0", "main": "dist/index.js", @@ -13,7 +13,7 @@ }, "scripts": { "start": "tsdx watch", - "build": "tsdx build", + "build": "tsdx build --ignore-pattern tests/*", "test": "tsdx test --passWithNoTests", "lint": "eslint . --ext js,ts,jsx,tsx", "prepare": "tsdx build", @@ -83,5 +83,8 @@ "**/glob-parent": "5.1.2", "**/ws": "7.4.6", "**/ansi-regex": "5.0.1" + }, + "jest": { + "testEnvironment": "node" } } diff --git a/sdk/src/quotes.ts b/sdk/src/quotes.ts index b5dcd4a..55244e9 100644 --- a/sdk/src/quotes.ts +++ b/sdk/src/quotes.ts @@ -26,11 +26,14 @@ import { PERMIT_EXPIRATION_TS, RAINBOW_ROUTER_CONTRACT_ADDRESS, RAINBOW_ROUTER_CONTRACT_ADDRESS_ZORA, - SOCKET_GATEWAY_CONTRACT_ADDRESSESS, WRAPPED_ASSET, } from './utils/constants'; import { signPermit } from './utils/permit'; import { getReferrerCode } from './utils/referrer'; +import { + extractDestinationAddress, + sanityCheckAddress, +} from './utils/sanity_check'; /** * Function to get the rainbow router contract address based on the chainId @@ -148,7 +151,7 @@ const buildRainbowCrosschainQuoteUrl = ({ swapType: SwapType.crossChain, toChainId: String(toChainId), }); - return `${API_BASE_URL}/v1/quote?bridgeVersion=2&` + searchParams.toString(); + return `${API_BASE_URL}/v1/quote?bridgeVersion=3&` + searchParams.toString(); }; /** @@ -278,7 +281,9 @@ export const getQuote = async ( * @param {BigNumberish} params.sellAmount * @param {number} params.slippage * @param {boolean} params.refuel - * @returns {Promise} + * @returns {Promise} returns error in case the request failed or the + * destination address is not consistent with the SDK's + * stored destination address */ export const getCrosschainQuote = async ( params: QuoteParams @@ -316,8 +321,24 @@ export const getCrosschainQuote = async ( } const quoteWithRestrictedAllowanceTarget = quote as CrosschainQuote; - quoteWithRestrictedAllowanceTarget.allowanceTarget = - SOCKET_GATEWAY_CONTRACT_ADDRESSESS.get(chainId); + try { + const { expectedAddress, shouldOverride } = sanityCheckAddress( + quoteWithRestrictedAllowanceTarget.source, + quoteWithRestrictedAllowanceTarget.chainId, + quoteWithRestrictedAllowanceTarget.allowanceTarget + ); + if (shouldOverride) { + quoteWithRestrictedAllowanceTarget.allowanceTarget = expectedAddress; + } + } catch (e) { + return { + error: true, + message: + e instanceof Error + ? e.message + : `unexpected error happened while checking crosschain quote's address: ${quoteWithRestrictedAllowanceTarget.allowanceTarget}`, + } as QuoteError; + } return quoteWithRestrictedAllowanceTarget; }; @@ -490,7 +511,15 @@ export const fillCrosschainQuote = async ( ): Promise => { const { data, from, value } = quote; - const to = SOCKET_GATEWAY_CONTRACT_ADDRESSESS.get(quote.fromChainId); + let to = quote.to; + const { expectedAddress, shouldOverride } = sanityCheckAddress( + quote.source, + quote.fromChainId, + extractDestinationAddress(quote) + ); + if (shouldOverride) { + to = expectedAddress; + } let txData = data; if (referrer) { @@ -589,7 +618,16 @@ export const getCrosschainQuoteExecutionDetails = ( provider: StaticJsonRpcProvider ): CrosschainQuoteExecutionDetails => { const { from, data, value } = quote; - const to = SOCKET_GATEWAY_CONTRACT_ADDRESSESS.get(quote.fromChainId); + + let to = quote.to; + const { expectedAddress, shouldOverride } = sanityCheckAddress( + quote.source, + quote.fromChainId, + extractDestinationAddress(quote) + ); + if (shouldOverride) { + to = expectedAddress; + } return { method: provider.estimateGas({ diff --git a/sdk/src/types/index.ts b/sdk/src/types/index.ts index 10f1ad7..22c75b9 100644 --- a/sdk/src/types/index.ts +++ b/sdk/src/types/index.ts @@ -21,8 +21,13 @@ export enum ChainId { export enum Source { Aggregator0x = '0x', Aggregator1inch = '1inch', + AggregatorRainbow = 'rainbow', // DEPRECATED: Use Aggregator1inch instead Aggregotor1inch = '1inch', + + // Crosschain + CrosschainAggregatorSocket = 'socket', + CrosschainAggregatorRelay = 'relay', } export enum SwapType { @@ -234,6 +239,7 @@ export interface CrosschainQuote extends Quote { allowanceTarget?: string; routes: SocketRoute[]; refuel: SocketRefuelData | null; + no_approval: boolean | undefined; } export interface TransactionOptions { diff --git a/sdk/src/utils/constants.ts b/sdk/src/utils/constants.ts index b4f9596..fd01b49 100644 --- a/sdk/src/utils/constants.ts +++ b/sdk/src/utils/constants.ts @@ -21,6 +21,12 @@ export const SOCKET_GATEWAY_CONTRACT_ADDRESSESS = new Map([ [ChainId.blast, '0x3a23F943181408EAC424116Af7b7790c94Cb97a5'], ]); +// RELAY_LINK_BRIDGING_RELAYER_ADDRESS is the EOA used by relay link as relayer on all chains +export const RELAY_LINK_BRIDGING_RELAYER_ADDRESS = + '0xf70da97812CB96acDF810712Aa562db8dfA3dbEF'; + +export const ERC20_TRANSFER_SIGNATURE = `0xa9059cbb`; + export type MultiChainAsset = { [key: string]: EthereumAddress; }; diff --git a/sdk/src/utils/sanity_check.ts b/sdk/src/utils/sanity_check.ts new file mode 100644 index 0000000..7cba331 --- /dev/null +++ b/sdk/src/utils/sanity_check.ts @@ -0,0 +1,167 @@ +import { ChainId, CrosschainQuote, Source } from '../types'; +import { + ERC20_TRANSFER_SIGNATURE, + ETH_ADDRESS, + RELAY_LINK_BRIDGING_RELAYER_ADDRESS, + SOCKET_GATEWAY_CONTRACT_ADDRESSESS, +} from './constants'; + +/** + * Sanity checks the quote's returned address against the expected address stored in the SDK. + * This function ensures the integrity and correctness of the destination address provided by the quote source. + * + * @param quoteSource The aggregator used for the quote. + * @param chainID The origin network chain ID for the quote. + * @param assertedAddress The destination address provided by the quote. + * @returns {string, boolean} The destination address stored in the SDK for the provided (source, chainID) combination. + * And if it should be overridden in the quote. + * @throws {Error} Throws an error if any of the following conditions are met: + * - The quote's destination address is undefined. + * - No destination address is defined in the SDK for the provided (source, chainID) combination. + * - The provided quote's destination address does not case-insensitively match the SDK's stored destination address. + */ +export function sanityCheckAddress( + quoteSource: Source | undefined, + chainID: ChainId, + assertedAddress: string | undefined +): { + expectedAddress: string; + shouldOverride: boolean; +} { + if (assertedAddress === undefined || assertedAddress === '') { + throw new Error( + `quote's destination addresses must be defined (API Response)` + ); + } + const { expectedAddress, shouldOverride } = getExpectedDestinationAddress( + quoteSource, + chainID + ); + if (expectedAddress === undefined || expectedAddress === '') { + throw new Error( + `expected source ${quoteSource}'s destination address on chainID ${chainID} must be defined (Swap SDK)` + ); + } + if (expectedAddress.toLowerCase() !== assertedAddress?.toLowerCase()) { + throw new Error( + `source ${quoteSource}'s destination address '${assertedAddress}' on chainID ${chainID} is not consistent, expected: '${expectedAddress}'` + ); + } + return { expectedAddress, shouldOverride }; +} + +/** + * Retrieves the destination address from a cross-chain quote object, returning undefined + * when the quote source is not known or the quote does not contain a valid destination address. + * + * @param quote The cross-chain quote object returned by the API. + * + * @returns The destination address as a string if available. + * Returns undefined if the quote does not properly specify a destination. + * + * @example + * // Example for a quote from socket + * const quoteSocket = { + * to: '0x1234567890123456789012345678901234567890', + * data: '0x...', + * sellTokenAddress: '0x...' + * }; + * console.log(getToAddressFromCrosschainQuote(Source.CrosschainAggregatorSocket, quoteSocket)); + * // Output: '0x1234567890123456789012345678901234567890' + * + * // Example for a quote from CrosschainAggregatorRelay where the sell token is ETH + * const quoteRelayETH = { + * to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + * data: '0x...', + * sellTokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' + * }; + * console.log(getToAddressFromCrosschainQuote(Source.CrosschainAggregatorRelay, quoteRelayETH)); + * // Output: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef' + * + * // Example for a quote from CrosschainAggregatorRelay where the sell token is not ETH + * const quoteRelayERC20 = { + * to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', + * data: '0xa9059cbb000000000000000000000000f70da97812cb96acdf810712aa562db8dfa3dbef...', + * sellTokenAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef' + * }; + * console.log(getToAddressFromCrosschainQuote(Source.CrosschainAggregatorRelay, quoteRelayERC20)); + * // Output: '0xf70da97812cb96acdf810712aa562db8dfa3dbef' (assuming the call data was a ERC20 transfer) + */ +export function extractDestinationAddress( + quote: CrosschainQuote +): string | undefined { + const quoteSource = quote.source; + const validQuoteSource = quoteSource !== undefined; + if (validQuoteSource && quoteSource === Source.CrosschainAggregatorSocket) { + return quote.to; + } + if (validQuoteSource && quoteSource === Source.CrosschainAggregatorRelay) { + if (quote.sellTokenAddress?.toLowerCase() === ETH_ADDRESS.toLowerCase()) { + return quote.to; + } + return decodeERC20TransferToData(quote.data); + } + return undefined; +} + +/** + * Decodes the ERC-20 token transfer data from a transaction's input data. + * This function expects the input data to start with the ERC-20 transfer method ID (`0xa9059cbb`), + * followed by the 64 hexadecimal characters for the destination address and 64 hexadecimal characters + * for the transfer amount. The function will check and parse the input data, extracting the recipient's address + * + * The method assumes the data is properly formatted and begins with the correct method ID. + * If the data does not conform to these expectations, the function will return an 'undefined' object. + * + * @param data The hex encoded input data string from an ERC-20 transfer transaction. This string + * should include the method ID followed by the encoded parameters (address and amount). + * + * @returns { string | undefined } The destination address. If any error happens. + * Returns 'undefined' if it could not decode the call data. + */ +export function decodeERC20TransferToData( + data: string | undefined +): string | undefined { + if (!data?.startsWith(ERC20_TRANSFER_SIGNATURE)) { + return undefined; + } + const paramsData = data.slice(ERC20_TRANSFER_SIGNATURE.length); + if (paramsData.length < 64 * 2) { + return undefined; + } + return `0x${paramsData.slice(0, 64).replace(/^0+/, '')}`; +} + +/** + * Retrieves the destination address stored in the SDK corresponding to the specified aggregator and chain ID. + * + * @param quoteSource The aggregator used for the quote. + * @param chainID The origin network chain ID for the quote. + * @returns {string | undefined, boolean} The destination address stored in the SDK for the provided (source, chainID) + * combination and if we need to overwrite it on the quote. + * Returns `undefined` if there is no address for the specified combination. + */ +export function getExpectedDestinationAddress( + quoteSource: Source | undefined, + chainID: ChainId +): { + expectedAddress: string | undefined; + shouldOverride: boolean; +} { + const validSource = quoteSource !== undefined; + if (validSource && quoteSource === Source.CrosschainAggregatorSocket) { + return { + expectedAddress: SOCKET_GATEWAY_CONTRACT_ADDRESSESS.get(chainID), + shouldOverride: true, + }; + } else if (validSource && quoteSource === Source.CrosschainAggregatorRelay) { + return { + expectedAddress: RELAY_LINK_BRIDGING_RELAYER_ADDRESS, + shouldOverride: false, + }; + } + return { + expectedAddress: undefined, + shouldOverride: false, + }; +} diff --git a/sdk/tests/utils/sanity_check.test.ts b/sdk/tests/utils/sanity_check.test.ts new file mode 100644 index 0000000..095988f --- /dev/null +++ b/sdk/tests/utils/sanity_check.test.ts @@ -0,0 +1,153 @@ +import { ChainId, CrosschainQuote, EthereumAddress, Source } from '../../src'; +import { + decodeERC20TransferToData, + extractDestinationAddress, + getExpectedDestinationAddress, + sanityCheckAddress, +} from '../../src/utils/sanity_check'; + +const okERC20Data = + '0xa9059cbb000000000000000000000000f70da97812cb96acdf810712aa562db8dfa3dbef0000000000000000000000000000000000000000000000056bc75e2d631000000085078f'; + +function getQuote( + chainID: ChainId, + source: Source | undefined = undefined, + to: string | undefined = undefined, + sellTokenAddress: string | undefined = undefined, + data: string | undefined = undefined +): CrosschainQuote { + return { + buyAmount: '', + buyAmountDisplay: '', + buyAmountInEth: '', + buyAmountMinusFees: '', + buyTokenAddress: '' as EthereumAddress, + chainId: chainID, + data: data, + fee: '', + feeInEth: '', + feePercentageBasisPoints: 0, + from: '', + fromAsset: { + address: '', + chainAgnosticId: chainID, + chainId: chainID, + decimals: 18, + icon: '', + logoURI: '', + name: '', + symbol: '', + }, + fromChainId: chainID, + no_approval: false, + refuel: null, + routes: [], + sellAmount: '', + sellAmountDisplay: '', + sellAmountInEth: '', + sellAmountMinusFees: '', + sellTokenAddress: sellTokenAddress as EthereumAddress, + source: source, + to: to, + toAsset: { + address: '', + chainAgnosticId: chainID, + chainId: chainID, + decimals: 18, + icon: '', + logoURI: '', + name: '', + symbol: '', + }, + toChainId: 0, + tradeAmountUSD: 0, + }; +} + +describe('getToAddressFromCrosschainQuote', () => { + it('should return undefined if non defined source', () => { + const quote = getQuote(1, undefined, '0x1234'); + expect(extractDestinationAddress(quote)).toBeUndefined(); + }); + it('should just use to address for socket', () => { + const quote = getQuote(1, Source.CrosschainAggregatorSocket, '0x1234'); + expect(extractDestinationAddress(quote)).toEqual('0x1234'); + }); +}); + +describe('decodeERC20TransferData', () => { + it('should correctly decode valid ERC20 transfer data', () => { + expect(decodeERC20TransferToData(okERC20Data)).toEqual( + '0xf70da97812cb96acdf810712aa562db8dfa3dbef' + ); + }); + it('should return null for invalid data', () => { + const data = '0xdeadbeef'; + expect(decodeERC20TransferToData(data)).toBeUndefined(); + }); + it('should return null for incomplete data', () => { + const data = + '0xa9059cbb000000000000000000000000f70da97812cb96acdf810712aa562db8dfa3dbef'; + expect(decodeERC20TransferToData(data)).toBeUndefined(); + }); + + it('should return decode erc20 data', () => { + const data = + '0xa9059cbb000000000000000000000000f70da97812cb96acdf810712aa562db8dfa3dbef000000000000000000000000000000000000000000000001a055690d9db8000000878469'; + expect(decodeERC20TransferToData(data)).toEqual( + `0xf70da97812cb96acdf810712aa562db8dfa3dbef` + ); + }); +}); + +describe('getExpectedDestinationAddress', () => { + it('should return expected and true for socket', () => { + expect( + getExpectedDestinationAddress( + Source.CrosschainAggregatorSocket, + ChainId.mainnet + ) + ).toEqual({ + expectedAddress: '0x3a23F943181408EAC424116Af7b7790c94Cb97a5', + shouldOverride: true, + }); + }); + it('should return expected and false for relay', () => { + expect( + getExpectedDestinationAddress( + Source.CrosschainAggregatorRelay, + ChainId.mainnet + ) + ).toEqual({ + expectedAddress: '0xf70da97812CB96acDF810712Aa562db8dfA3dbEF', + shouldOverride: false, + }); + }); +}); + +describe('sanityCheckAddress', () => { + it('should return expected and true for socket', () => { + expect( + sanityCheckAddress( + Source.CrosschainAggregatorSocket, + ChainId.mainnet, + '0x3a23F943181408EAC424116Af7b7790c94Cb97a5' + ) + ).toEqual({ + expectedAddress: '0x3a23F943181408EAC424116Af7b7790c94Cb97a5', + shouldOverride: true, + }); + }); + it('should return expected and false for relay', () => { + expect( + sanityCheckAddress( + Source.CrosschainAggregatorRelay, + ChainId.mainnet, + '0xf70da97812CB96acDF810712Aa562db8dfA3dbEF' + ) + ).toEqual({ + expectedAddress: '0xf70da97812CB96acDF810712Aa562db8dfA3dbEF', + shouldOverride: false, + }); + }); +}); diff --git a/sdk/yarn.lock b/sdk/yarn.lock index 3aebb8f..e18ff8a 100644 --- a/sdk/yarn.lock +++ b/sdk/yarn.lock @@ -9363,4 +9363,4 @@ yargs@^15.3.1: string-width "^4.2.0" which-module "^2.0.0" y18n "^4.0.0" - yargs-parser "^18.1.2" + yargs-parser "^18.1.2" \ No newline at end of file