diff --git a/README.md b/README.md index e5ac4af094..c7fc6fbf96 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Table of contents - [Link packages to each other](#link-packages-to-each-other) - [Generate OpenAPI clients](#generate-openapi-clients) - [Building](#building) + - [API key and Publishable key](#API-key-and-Publishable-key) - [Linting](#linting) - [ESLint Tooling](#eslint-tooling) - [Exclude Lists](#exclude-lists) @@ -121,22 +122,51 @@ If you run out of memory, set NODE_OPTIONS to limit Node's use of memory (this a export NODE_OPTIONS=--max-old-space-size=14366 ``` -### API keys and Client App Id +### API key and Publishable key + +You can mark the API key and/or publishable key as required fields or remove them from your SDK configuration: +```ts +// We use checkout sdk as an example. +export interface CheckoutOverrides { + // The below marks CheckoutModuleConfiguration to have publishableKey field required and apiKey field omitted. + apiKey: "omit"; + publishableKey: "required"; +} +export interface CheckoutModuleConfiguration extends ModuleConfiguration {} +``` -API keys and Client App ID are used to authenticate and track usage respectively per partner/project/environment. +`Api key` and `Publishable key` are meant to be added to request headers. -Once created from hub, they can be optionally passed into base config as followed: +For `Api key`, it's done automatically by generated client. No action required. -``` -import { config } from '@imtbl/sdk'; +For `Publishable key`, we can do the following: + +```ts +export class Checkout { + constructor( + config: CheckoutModuleConfiguration, + ) { + // imported from '@imtbl/config', addPublishableKeyToAxiosHeader + // adds x-immutable-publishable-key header to all axios requests + addPublishableKeyToAxiosHeader(config.baseConfig.publishableKey); + + // You can also remove these headers. + axios.defaults.headers.common['x-immutable-publishable-key'] = undefined; -const baseConfig = new config.ImmutableConfiguration({ - environment: config.Environment.PRODUCTION, - clientAppId: '....', - apiKey: '....', -}); + // Or apply them to particular request methods + axios.defaults.headers.delete['x-immutable-publishable-key'] = undefined; + + // Or you can save the config in the instance of this class and reference them in individual methods. + } +} ``` +> **Warning** +> Please make sure your sdk still works properly after the step above. Because extra headers may make your request invalid in the infrastructure you use. e.g. cloudfront. + +#### Publishable key usage data +Publishable key usage will be available in segment under event source `Onboarding - API - ${Dev|Sandbox|Prod}`. You can set up event destination (data lake/looker) together with appropriate filters to surface endpoint usages called by your sdk. + ### Linting #### ESLint Tooling diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index c87cc80851..b9e1207cb5 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -11,35 +11,50 @@ export class ImmutableConfiguration { readonly apiKey?: string; - readonly clientAppId?: string; + readonly publishableKey?: string; - constructor(options: { environment: Environment; rateLimitingKey?: string; apiKey?: string; clientAppId?: string }) { + constructor(options: { + environment: Environment; + }) { this.environment = options.environment; - - if (options.rateLimitingKey) { - this.rateLimitingKey = options.rateLimitingKey; - axios.defaults.headers.common['x-api-key'] = this.rateLimitingKey; - } - - if (options.apiKey) { - if (!options.apiKey.startsWith('sk_imapik-')) { - throw new Error('Invalid API key'); - } - this.apiKey = options.apiKey; - axios.defaults.headers.common['x-immutable-api-key'] = this.apiKey; - } - - if (options.clientAppId) { - if (!options.clientAppId.startsWith('cai_imapik-')) { - throw new Error('Invalid Client App Id'); - } - this.clientAppId = options.clientAppId; - axios.defaults.headers.common['x-immutable-client-app-id'] = this.clientAppId; - } } } +const API_KEY_PREFIX = 'sk_imapik-'; +const PUBLISHABLE_KEY_PREFIX = 'pk_imapik-'; +const PUBLISHABLE_KEY_LENGTH = 30; + +export const addApiKeyToAxiosHeader = (apiKey: string) => { + if (!apiKey.startsWith(API_KEY_PREFIX)) { + throw new Error('Invalid API key. Create your api key in Immutable developer hub. https://hub.immutable.com'); + } + axios.defaults.headers.common['x-immutable-api-key'] = apiKey; +}; + +export const addPublishableKeyToAxiosHeader = (publishableKey: string) => { + if (!publishableKey.startsWith(PUBLISHABLE_KEY_PREFIX) || publishableKey.length !== PUBLISHABLE_KEY_LENGTH) { + throw new Error( + 'Invalid Publishable key. Create your Publishable key in Immutable developer hub.' + + ' https://hub.immutable.com', + ); + } + axios.defaults.headers.common['x-immutable-publishable-key'] = publishableKey; +}; + +export const addRateLimitingKeyToAxiosHeader = (rateLimitingKey: string) => { + axios.defaults.headers.common['x-api-key'] = rateLimitingKey; +}; + +type ImmutableConfigurationWithRequireableFields = ImmutableConfiguration & +(T extends { apiKey: 'required'; } ? Required<{ apiKey: string; }> : {}) & +(T extends { publishableKey: 'required'; } ? Required<{ publishableKey: string; }> : {}); + +type ImmutableConfigurationWithOmitableFields = + (T extends { apiKey: 'omit'; } ? + Omit, 'apiKey'> : + ImmutableConfigurationWithRequireableFields); + export interface ModuleConfiguration { - baseConfig: ImmutableConfiguration; + baseConfig: ImmutableConfigurationWithOmitableFields; overrides?: T; } diff --git a/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountIn.test.ts b/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountIn.test.ts index 946685de1a..f9c85eb5b6 100644 --- a/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountIn.test.ts +++ b/packages/internal/dex/sdk/src/exchange.getUnsignedSwapTxFromAmountIn.test.ts @@ -106,7 +106,223 @@ describe('getUnsignedSwapTxFromAmountIn', () => { }); }); - describe('with a native token in', () => { + describe('with a single pool without fees and default slippage tolerance', () => { + it('generates valid swap calldata', async () => { + const params = setupSwapTxTest(); + + mockRouterImplementation(params); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + ); + + expectToBeDefined(swap.transaction.data); + + const data = swap.transaction.data.toString(); + + const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); + expectInstanceOf(BigNumber, swapParams.amountIn); + + expect(swapParams.tokenIn).toBe(params.inputToken); // input token + expect(swapParams.tokenOut).toBe(params.outputToken); // output token + expect(swapParams.fee).toBe(10000); // fee + expect(swapParams.recipient).toBe(params.fromAddress); // recipient + expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // to address + expect(swap.transaction.from).toBe(params.fromAddress); // from address + expect(swap.transaction.value).toBe('0x00'); // refers to 0ETH + expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // amount in + expect(formatEther(swapParams.amountOutMinimum)).toBe('999.000999000999000999'); // min amount out (includes slippage) + expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); // sqrtPriceX96Limit + }); + + it('returns the gas estimate for the swap', async () => { + const params = setupSwapTxTest(); + + mockRouterImplementation(params); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + const tx = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + ); + + expectToBeDefined(tx.swap.gasFeeEstimate); + + expect(tx.swap.gasFeeEstimate.value).toEqual(TEST_TRANSACTION_GAS_USAGE.mul(TEST_GAS_PRICE)); + expect(tx.swap.gasFeeEstimate.token.chainId).toEqual(NATIVE_TEST_TOKEN.chainId); + expect(tx.swap.gasFeeEstimate.token.address).toEqual(''); // Default configuration is a native token for gas and not an ERC20 + expect(tx.swap.gasFeeEstimate.token.decimals).toEqual(NATIVE_TEST_TOKEN.decimals); + expect(tx.swap.gasFeeEstimate.token.symbol).toEqual(NATIVE_TEST_TOKEN.symbol); + expect(tx.swap.gasFeeEstimate.token.name).toEqual(NATIVE_TEST_TOKEN.name); + }); + + it('returns valid quote', async () => { + const params = setupSwapTxTest(); + + mockRouterImplementation(params); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + const { quote } = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + ); + + expect(quote).not.toBe(undefined); + expect(quote.amount.token.address).toEqual(params.outputToken); + expect(quote.slippage).toBe(0.1); + expect(formatAmount(quote.amount)).toEqual('1000.0'); + expect(quote.amountWithMaxSlippage.token.address).toEqual(params.outputToken); + expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('999.000999000999000999'); // includes slippage + }); + }); + + describe('with a single pool without fees and high slippage tolerance', () => { + it('generates valid calldata', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ); + + expectToBeDefined(swap.transaction.data); + + const data = swap.transaction.data.toString(); + + const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); + expectInstanceOf(BigNumber, swapParams.amountIn); + + expect(swapParams.tokenIn).toBe(params.inputToken); // input token + expect(swapParams.tokenOut).toBe(params.outputToken); // output token + expect(swapParams.fee).toBe(10000); // fee + expect(swapParams.recipient).toBe(params.fromAddress); // recipient + expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // to address + expect(swap.transaction.from).toBe(params.fromAddress); // from address + expect(swap.transaction.value).toBe('0x00'); // refers to 0ETH + expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // amount in + expect(formatEther(swapParams.amountOutMinimum)).toBe('998.003992015968063872'); // min amount out (includes 0.2% slippage) + expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); // sqrtPriceX96Limit + }); + + it('returns valid quote', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); + + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + + const { quote } = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ); + + expect(quote.amount.token.address).toEqual(params.outputToken); + expect(quote.slippage).toBe(0.2); + expect(formatAmount(quote.amount)).toEqual('1000.0'); + expect(quote.amountWithMaxSlippage.token.address).toEqual(params.outputToken); + expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('998.003992015968063872'); // includes 0.2% slippage + }); + }); + + describe('with a single pool and secondary fees', () => { + it('generates valid swap calldata', async () => { + const params = setupSwapTxTest(); + const findOptimalRouteMock = mockRouterImplementation(params); + + const secondaryFees: SecondaryFee[] = [ + { recipient: TEST_FEE_RECIPIENT, basisPoints: 100 }, // 1% Fee + ]; + const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); + + const { swap, quote } = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + 3, // 3% Slippage + ); + + expectToBeDefined(swap.transaction.data); + + expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('961.165048543689320388'); // userQuoteRes.amountOutMinimum = swapReq.amountOutMinimum + + const ourQuoteReqAmountIn = findOptimalRouteMock.mock.calls[0][0]; + expect(formatAmount(ourQuoteReqAmountIn)).toEqual('99.0'); // ourQuoteReq.amountIn = the amount specified less the fee + + const data = swap.transaction.data.toString(); + + const { swapParams, secondaryFeeParams } = decodeMulticallExactInputSingleWithFees(data); + expectInstanceOf(BigNumber, swapParams.amountIn); + + expect(secondaryFeeParams[0].recipient).toBe(TEST_FEE_RECIPIENT); + expect(secondaryFeeParams[0].basisPoints.toString()).toBe('100'); + + expect(swapParams.tokenIn).toBe(params.inputToken); + expect(swapParams.tokenOut).toBe(params.outputToken); + expect(swapParams.fee).toBe(10000); + expect(swapParams.recipient).toBe(params.fromAddress); + expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // swap.amountIn = userQuoteReq.amountIn + expect(formatEther(swapParams.amountOutMinimum)).toBe('961.165048543689320388'); // swap.amountOutMinimum = ourQuoteRes.amountOut - slippage + expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); + + expect(swap.transaction.to).toBe(TEST_SECONDARY_FEE_ADDRESS); + expect(swap.transaction.from).toBe(params.fromAddress); + expect(swap.transaction.value).toBe('0x00'); + }); + }); + + describe('with a single pool and secondary fees and a native token in', () => { + it('should include the user-specified amount as the value of the transaction', async () => { + mockRouterImplementation({ + pools: [createPool(nativeTokenService.wrappedToken, FUN_TEST_TOKEN)], + }); + + const secondaryFees: SecondaryFee[] = [ + { recipient: TEST_FEE_RECIPIENT, basisPoints: 100 }, // 1% Fee + ]; + const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); + + const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( + TEST_FROM_ADDRESS, + 'native', + FUN_TEST_TOKEN.address, + newAmountFromString('100', FUN_TEST_TOKEN).value, + ); + + expectToBeDefined(swap.transaction.data); + expectToBeDefined(swap.transaction.value); + const data = swap.transaction.data.toString(); + + const { swapParams } = decodeMulticallExactInputSingleWithFees(data); + expectInstanceOf(BigNumber, swapParams.amountIn); + + expect(swapParams.tokenIn).toBe(WIMX_TEST_TOKEN.address); // should be the wrapped native token + expect(swapParams.tokenOut).toBe(FUN_TEST_TOKEN.address); + expect(swap.transaction.value).toBe('0x056bc75e2d63100000'); // should be a hex + expect(formatTokenAmount(swapParams.amountIn, WIMX_TEST_TOKEN)).toBe('100.0'); // amount in + }); + }); + + describe('with a single pool and a native token in', () => { it('should include the user-specified amount as the value of the transaction', async () => { mockRouterImplementation({ pools: [createPool(nativeTokenService.wrappedToken, FUN_TEST_TOKEN)], @@ -312,7 +528,7 @@ describe('getUnsignedSwapTxFromAmountIn', () => { }); }); - describe('with multiple pools and a native token out', () => { + describe('with multiple pools', () => { describe('with a native token out', () => { it('should specify the Router contract as the recipient of the swap function call', async () => { mockRouterImplementation({ @@ -343,144 +559,6 @@ describe('getUnsignedSwapTxFromAmountIn', () => { }); }); - describe('When the swap transaction requires approval', () => { - it('should include the unsigned approval transaction', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); - const erc20ContractInterface = ERC20__factory.createInterface(); - - const exchange = new Exchange(TEST_DEX_CONFIGURATION); - - const amountIn = addAmount(APPROVED_AMOUNT, newAmountFromString('1', USDC_TEST_TOKEN)); - const tx = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - amountIn.value, - ); - - expectToBeDefined(tx.approval?.transaction.data); - - const decodedResults = erc20ContractInterface.decodeFunctionData('approve', tx.approval.transaction.data); - expect(decodedResults[0]).toEqual(TEST_ROUTER_ADDRESS); - // we have already approved 1000000000000000000 but this is not enough, so we expect to approve the full amount - expect(decodedResults[1].toString()).toEqual(amountIn.value.toString()); - expect(tx.approval.transaction.to).toEqual(params.inputToken); - expect(tx.approval.transaction.from).toEqual(params.fromAddress); - expect(tx.approval.transaction.value).toEqual(0); // we do not want to send any ETH - }); - - it('should include the gas estimate for the approval transaction', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); - - const exchange = new Exchange(TEST_DEX_CONFIGURATION); - - const amountIn = addAmount(APPROVED_AMOUNT, newAmountFromString('1', USDC_TEST_TOKEN)); - const tx = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - amountIn.value, - ); - - expectToBeDefined(tx.approval?.gasFeeEstimate); - expect(tx.approval.gasFeeEstimate.value).toEqual(TEST_GAS_PRICE.mul(APPROVE_GAS_ESTIMATE)); - expect(tx.approval.gasFeeEstimate.token.chainId).toEqual(NATIVE_TEST_TOKEN.chainId); - expect(tx.approval.gasFeeEstimate.token.address).toEqual(''); - expect(tx.approval.gasFeeEstimate.token.decimals).toEqual(NATIVE_TEST_TOKEN.decimals); - expect(tx.approval.gasFeeEstimate.token.symbol).toEqual(NATIVE_TEST_TOKEN.symbol); - expect(tx.approval.gasFeeEstimate.token.name).toEqual(NATIVE_TEST_TOKEN.name); - }); - }); - - describe('When the swap transaction does not require approval', () => { - it('should not include the unsigned approval transaction', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); - - const exchange = new Exchange(TEST_DEX_CONFIGURATION); - - // Set the amountIn to be the same as the APPROVED_AMOUNT - const tx = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - APPROVED_AMOUNT.value, - ); - - // we have already approved 1000000000000000000, so we don't expect to approve anything - expect(tx.approval).toBe(null); - }); - }); - - describe('When no route found', () => { - it('throws NoRoutesAvailableError', async () => { - const params = setupSwapTxTest(); - - (Router as unknown as jest.Mock).mockImplementationOnce(() => ({ - findOptimalRoute: jest.fn().mockRejectedValue(new NoRoutesAvailableError()), - })); - - const exchange = new Exchange(TEST_DEX_CONFIGURATION); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - ), - ).rejects.toThrow(new NoRoutesAvailableError()); - }); - }); - - describe('Swap with single pool and secondary fees', () => { - it('generates valid swap calldata', async () => { - const params = setupSwapTxTest(); - const findOptimalRouteMock = mockRouterImplementation(params); - - const secondaryFees: SecondaryFee[] = [ - { recipient: TEST_FEE_RECIPIENT, basisPoints: 100 }, // 1% Fee - ]; - const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); - - const { swap, quote } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - 3, // 3% Slippage - ); - - expectToBeDefined(swap.transaction.data); - - expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('961.165048543689320388'); // userQuoteRes.amountOutMinimum = swapReq.amountOutMinimum - - const ourQuoteReqAmountIn = findOptimalRouteMock.mock.calls[0][0]; - expect(formatAmount(ourQuoteReqAmountIn)).toEqual('99.0'); // ourQuoteReq.amountIn = the amount specified less the fee - - const data = swap.transaction.data.toString(); - - const { swapParams, secondaryFeeParams } = decodeMulticallExactInputSingleWithFees(data); - expectInstanceOf(BigNumber, swapParams.amountIn); - - expect(secondaryFeeParams[0].recipient).toBe(TEST_FEE_RECIPIENT); - expect(secondaryFeeParams[0].basisPoints.toString()).toBe('100'); - - expect(swapParams.tokenIn).toBe(params.inputToken); - expect(swapParams.tokenOut).toBe(params.outputToken); - expect(swapParams.fee).toBe(10000); - expect(swapParams.recipient).toBe(params.fromAddress); - expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // swap.amountIn = userQuoteReq.amountIn - expect(formatEther(swapParams.amountOutMinimum)).toBe('961.165048543689320388'); // swap.amountOutMinimum = ourQuoteRes.amountOut - slippage - expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); - - expect(swap.transaction.to).toBe(TEST_SECONDARY_FEE_ADDRESS); - expect(swap.transaction.from).toBe(params.fromAddress); - expect(swap.transaction.value).toBe('0x00'); - }); - }); - describe('Swap with multiple pools and secondary fees', () => { it('generates valid swap calldata', async () => { const params = setupSwapTxTest({ multiPoolSwap: true }); @@ -554,9 +632,45 @@ describe('getUnsignedSwapTxFromAmountIn', () => { ]); }); }); + }); + + describe('Swap with secondary fees and paused secondary fee contract', () => { + it('should use the default router contract with no fees applied to the swap', async () => { + erc20Contract = (Contract as unknown as jest.Mock).mockImplementation(() => ({ + allowance: jest.fn().mockResolvedValue(APPROVED_AMOUNT.value), + estimateGas: { approve: jest.fn().mockResolvedValue(APPROVE_GAS_ESTIMATE) }, + paused: jest.fn().mockResolvedValue(true), + })); + + const params = setupSwapTxTest(); + mockRouterImplementation(params); + + const secondaryFees: SecondaryFee[] = [ + { recipient: TEST_FEE_RECIPIENT, basisPoints: 100 }, // 1% Fee + ]; + const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); + + const { swap, quote } = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + ); + + expectToBeDefined(swap.transaction.data); + + expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('999.000999000999000999'); // min amount out (includes slippage) + expect(quote.fees.length).toBe(0); // expect no fees to be applied + + const data = swap.transaction.data.toString(); + const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); + + expect(formatEther(swapParams.amountOutMinimum)).toBe(formatEther(quote.amountWithMaxSlippage.value)); + expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // expect the default router contract to be used + }); - describe('Swap with secondary fees and paused secondary fee contract', () => { - it('should use the default router contract with no fees applied to the swap', async () => { + describe('when the secondary fee contract is unpaused after a swap request', () => { + it('should apply secondary fees to a subsequent swap request', async () => { erc20Contract = (Contract as unknown as jest.Mock).mockImplementation(() => ({ allowance: jest.fn().mockResolvedValue(APPROVED_AMOUNT.value), estimateGas: { approve: jest.fn().mockResolvedValue(APPROVE_GAS_ESTIMATE) }, @@ -571,373 +685,291 @@ describe('getUnsignedSwapTxFromAmountIn', () => { ]; const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); + await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + 3, // 3% Slippage + ); + + // Unpause the secondary fee contract + erc20Contract = (Contract as unknown as jest.Mock).mockImplementation(() => ({ + allowance: jest.fn().mockResolvedValue(APPROVED_AMOUNT.value), + estimateGas: { approve: jest.fn().mockResolvedValue(APPROVE_GAS_ESTIMATE) }, + paused: jest.fn().mockResolvedValue(false), + })); + const { swap, quote } = await exchange.getUnsignedSwapTxFromAmountIn( params.fromAddress, params.inputToken, params.outputToken, newAmountFromString('100', USDC_TEST_TOKEN).value, + 3, // 3% Slippage ); expectToBeDefined(swap.transaction.data); - expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('999.000999000999000999'); // min amount out (includes slippage) - expect(quote.fees.length).toBe(0); // expect no fees to be applied + expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('961.165048543689320388'); // min amount out (includes slippage) + expect(quote.fees.length).toBe(1); // expect no fees to be applied const data = swap.transaction.data.toString(); - const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); + const { swapParams } = decodeMulticallExactInputSingleWithFees(data); expect(formatEther(swapParams.amountOutMinimum)).toBe(formatEther(quote.amountWithMaxSlippage.value)); - expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // expect the default router contract to be used - }); - - describe('when the secondary fee contract is unpaused after a swap request', () => { - it('should apply secondary fees to a subsequent swap request', async () => { - erc20Contract = (Contract as unknown as jest.Mock).mockImplementation(() => ({ - allowance: jest.fn().mockResolvedValue(APPROVED_AMOUNT.value), - estimateGas: { approve: jest.fn().mockResolvedValue(APPROVE_GAS_ESTIMATE) }, - paused: jest.fn().mockResolvedValue(true), - })); - - const params = setupSwapTxTest(); - mockRouterImplementation(params); - - const secondaryFees: SecondaryFee[] = [ - { recipient: TEST_FEE_RECIPIENT, basisPoints: 100 }, // 1% Fee - ]; - const exchange = new Exchange({ ...TEST_DEX_CONFIGURATION, secondaryFees }); - - await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - 3, // 3% Slippage - ); - - // Unpause the secondary fee contract - erc20Contract = (Contract as unknown as jest.Mock).mockImplementation(() => ({ - allowance: jest.fn().mockResolvedValue(APPROVED_AMOUNT.value), - estimateGas: { approve: jest.fn().mockResolvedValue(APPROVE_GAS_ESTIMATE) }, - paused: jest.fn().mockResolvedValue(false), - })); - - const { swap, quote } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - 3, // 3% Slippage - ); - - expectToBeDefined(swap.transaction.data); - - expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('961.165048543689320388'); // min amount out (includes slippage) - expect(quote.fees.length).toBe(1); // expect no fees to be applied - - const data = swap.transaction.data.toString(); - const { swapParams } = decodeMulticallExactInputSingleWithFees(data); - - expect(formatEther(swapParams.amountOutMinimum)).toBe(formatEther(quote.amountWithMaxSlippage.value)); - expect(swap.transaction.to).toBe(TEST_SECONDARY_FEE_ADDRESS); // expect the secondary fee contract to be used - }); + expect(swap.transaction.to).toBe(TEST_SECONDARY_FEE_ADDRESS); // expect the secondary fee contract to be used }); }); + }); - describe('Swap with single pool without fees and default slippage tolerance', () => { - it('generates valid swap calldata', async () => { - const params = setupSwapTxTest(); - - mockRouterImplementation(params); + describe('When the swap transaction requires approval', () => { + it('should include the unsigned approval transaction', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); + const erc20ContractInterface = ERC20__factory.createInterface(); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); - - const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - ); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - expectToBeDefined(swap.transaction.data); + const amountIn = addAmount(APPROVED_AMOUNT, newAmountFromString('1', USDC_TEST_TOKEN)); + const tx = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + amountIn.value, + ); - const data = swap.transaction.data.toString(); + expectToBeDefined(tx.approval?.transaction.data); - const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); - expectInstanceOf(BigNumber, swapParams.amountIn); + const decodedResults = erc20ContractInterface.decodeFunctionData('approve', tx.approval.transaction.data); + expect(decodedResults[0]).toEqual(TEST_ROUTER_ADDRESS); + // we have already approved 1000000000000000000 but this is not enough, so we expect to approve the full amount + expect(decodedResults[1].toString()).toEqual(amountIn.value.toString()); + expect(tx.approval.transaction.to).toEqual(params.inputToken); + expect(tx.approval.transaction.from).toEqual(params.fromAddress); + expect(tx.approval.transaction.value).toEqual(0); // we do not want to send any ETH + }); - expect(swapParams.tokenIn).toBe(params.inputToken); // input token - expect(swapParams.tokenOut).toBe(params.outputToken); // output token - expect(swapParams.fee).toBe(10000); // fee - expect(swapParams.recipient).toBe(params.fromAddress); // recipient - expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // to address - expect(swap.transaction.from).toBe(params.fromAddress); // from address - expect(swap.transaction.value).toBe('0x00'); // refers to 0ETH - expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // amount in - expect(formatEther(swapParams.amountOutMinimum)).toBe('999.000999000999000999'); // min amount out (includes slippage) - expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); // sqrtPriceX96Limit - }); + it('should include the gas estimate for the approval transaction', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); - it('returns the gas estimate for the swap', async () => { - const params = setupSwapTxTest(); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - mockRouterImplementation(params); + const amountIn = addAmount(APPROVED_AMOUNT, newAmountFromString('1', USDC_TEST_TOKEN)); + const tx = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + amountIn.value, + ); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + expectToBeDefined(tx.approval?.gasFeeEstimate); + expect(tx.approval.gasFeeEstimate.value).toEqual(TEST_GAS_PRICE.mul(APPROVE_GAS_ESTIMATE)); + expect(tx.approval.gasFeeEstimate.token.chainId).toEqual(NATIVE_TEST_TOKEN.chainId); + expect(tx.approval.gasFeeEstimate.token.address).toEqual(''); + expect(tx.approval.gasFeeEstimate.token.decimals).toEqual(NATIVE_TEST_TOKEN.decimals); + expect(tx.approval.gasFeeEstimate.token.symbol).toEqual(NATIVE_TEST_TOKEN.symbol); + expect(tx.approval.gasFeeEstimate.token.name).toEqual(NATIVE_TEST_TOKEN.name); + }); + }); - const tx = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - ); + describe('When the swap transaction does not require approval', () => { + it('should not include the unsigned approval transaction', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); - expectToBeDefined(tx.swap.gasFeeEstimate); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - expect(tx.swap.gasFeeEstimate.value).toEqual(TEST_TRANSACTION_GAS_USAGE.mul(TEST_GAS_PRICE)); - expect(tx.swap.gasFeeEstimate.token.chainId).toEqual(NATIVE_TEST_TOKEN.chainId); - expect(tx.swap.gasFeeEstimate.token.address).toEqual(''); // Default configuration is a native token for gas and not an ERC20 - expect(tx.swap.gasFeeEstimate.token.decimals).toEqual(NATIVE_TEST_TOKEN.decimals); - expect(tx.swap.gasFeeEstimate.token.symbol).toEqual(NATIVE_TEST_TOKEN.symbol); - expect(tx.swap.gasFeeEstimate.token.name).toEqual(NATIVE_TEST_TOKEN.name); - }); + // Set the amountIn to be the same as the APPROVED_AMOUNT + const tx = await exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + APPROVED_AMOUNT.value, + ); - it('returns valid quote', async () => { - const params = setupSwapTxTest(); + // we have already approved 1000000000000000000, so we don't expect to approve anything + expect(tx.approval).toBe(null); + }); + }); - mockRouterImplementation(params); + describe('When no route found', () => { + it('throws NoRoutesAvailableError', async () => { + const params = setupSwapTxTest(); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + (Router as unknown as jest.Mock).mockImplementationOnce(() => ({ + findOptimalRoute: jest.fn().mockRejectedValue(new NoRoutesAvailableError()), + })); - const { quote } = await exchange.getUnsignedSwapTxFromAmountIn( + const exchange = new Exchange(TEST_DEX_CONFIGURATION); + await expect( + exchange.getUnsignedSwapTxFromAmountIn( params.fromAddress, params.inputToken, params.outputToken, newAmountFromString('100', USDC_TEST_TOKEN).value, - ); - - expect(quote).not.toBe(undefined); - expect(quote.amount.token.address).toEqual(params.outputToken); - expect(quote.slippage).toBe(0.1); - expect(formatAmount(quote.amount)).toEqual('1000.0'); - expect(quote.amountWithMaxSlippage.token.address).toEqual(params.outputToken); - expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('999.000999000999000999'); // includes slippage - }); + ), + ).rejects.toThrow(new NoRoutesAvailableError()); }); + }); - describe('Swap with single pool without fees and high slippage tolerance', () => { - it('generates valid calldata', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + describe('Pass in zero address', () => { + it('throws InvalidAddressError', async () => { + const params = setupSwapTxTest(); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - const { swap } = await exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, + const invalidAddress = constants.AddressZero; + + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + invalidAddress, params.inputToken, params.outputToken, newAmountFromString('100', USDC_TEST_TOKEN).value, HIGHER_SLIPPAGE, - ); + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid from address')); - expectToBeDefined(swap.transaction.data); - - const data = swap.transaction.data.toString(); - - const { swapParams } = decodeMulticallExactInputSingleWithoutFees(data); - expectInstanceOf(BigNumber, swapParams.amountIn); - - expect(swapParams.tokenIn).toBe(params.inputToken); // input token - expect(swapParams.tokenOut).toBe(params.outputToken); // output token - expect(swapParams.fee).toBe(10000); // fee - expect(swapParams.recipient).toBe(params.fromAddress); // recipient - expect(swap.transaction.to).toBe(TEST_ROUTER_ADDRESS); // to address - expect(swap.transaction.from).toBe(params.fromAddress); // from address - expect(swap.transaction.value).toBe('0x00'); // refers to 0ETH - expect(formatTokenAmount(swapParams.amountIn, USDC_TEST_TOKEN)).toBe('100.0'); // amount in - expect(formatEther(swapParams.amountOutMinimum)).toBe('998.003992015968063872'); // min amount out (includes 0.2% slippage) - expect(swapParams.sqrtPriceLimitX96.toString()).toBe('0'); // sqrtPriceX96Limit - }); - - it('returns valid quote', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); - - const exchange = new Exchange(TEST_DEX_CONFIGURATION); - - const { quote } = await exchange.getUnsignedSwapTxFromAmountIn( + await expect( + exchange.getUnsignedSwapTxFromAmountIn( params.fromAddress, - params.inputToken, + invalidAddress, params.outputToken, newAmountFromString('100', USDC_TEST_TOKEN).value, HIGHER_SLIPPAGE, - ); + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid token in address')); - expect(quote.amount.token.address).toEqual(params.outputToken); - expect(quote.slippage).toBe(0.2); - expect(formatAmount(quote.amount)).toEqual('1000.0'); - expect(quote.amountWithMaxSlippage.token.address).toEqual(params.outputToken); - expect(formatAmount(quote.amountWithMaxSlippage)).toEqual('998.003992015968063872'); // includes 0.2% slippage - }); + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + invalidAddress, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid token out address')); }); + }); - describe('Pass in zero address', () => { - it('throws InvalidAddressError', async () => { - const params = setupSwapTxTest(); + describe('Pass in invalid addresses', () => { + it('throws InvalidAddressError', async () => { + const params = setupSwapTxTest(); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - const invalidAddress = constants.AddressZero; - - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - invalidAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid from address')); - - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - invalidAddress, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid token in address')); - - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - invalidAddress, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid token out address')); - }); - }); + const invalidAddress = '0x0123abcdef'; - describe('Pass in invalid addresses', () => { - it('throws InvalidAddressError', async () => { - const params = setupSwapTxTest(); + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + invalidAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid from address')); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + invalidAddress, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid token in address')); - const invalidAddress = '0x0123abcdef'; - - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - invalidAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid from address')); - - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - invalidAddress, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid token in address')); - - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - invalidAddress, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - ), - ).rejects.toThrow(new InvalidAddressError('Error: invalid token out address')); - }); + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + invalidAddress, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + ), + ).rejects.toThrow(new InvalidAddressError('Error: invalid token out address')); }); + }); - describe('Pass in maxHops > 10', () => { - it('throws InvalidMaxHopsError', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + describe('Pass in maxHops > 10', () => { + it('throws InvalidMaxHopsError', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - 11, - ), - ).rejects.toThrow(new InvalidMaxHopsError('Error: max hops must be less than or equal to 10')); - }); + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + 11, + ), + ).rejects.toThrow(new InvalidMaxHopsError('Error: max hops must be less than or equal to 10')); }); + }); - describe('Pass in maxHops < 1', () => { - it('throws InvalidMaxHopsError', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + describe('Pass in maxHops < 1', () => { + it('throws InvalidMaxHopsError', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - HIGHER_SLIPPAGE, - 0, - ), - ).rejects.toThrow(new InvalidMaxHopsError('Error: max hops must be greater than or equal to 1')); - }); + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + HIGHER_SLIPPAGE, + 0, + ), + ).rejects.toThrow(new InvalidMaxHopsError('Error: max hops must be greater than or equal to 1')); }); + }); - describe('With slippage greater than 50', () => { - it('throws InvalidSlippageError', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + describe('With slippage greater than 50', () => { + it('throws InvalidSlippageError', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - 100, - 2, - ), - ).rejects.toThrow(new InvalidSlippageError('Error: slippage percent must be less than or equal to 50')); - }); + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + 100, + 2, + ), + ).rejects.toThrow(new InvalidSlippageError('Error: slippage percent must be less than or equal to 50')); }); + }); - describe('With slippage less than 0', () => { - it('throws InvalidSlippageError', async () => { - const params = setupSwapTxTest(); - mockRouterImplementation(params); + describe('With slippage less than 0', () => { + it('throws InvalidSlippageError', async () => { + const params = setupSwapTxTest(); + mockRouterImplementation(params); - const exchange = new Exchange(TEST_DEX_CONFIGURATION); + const exchange = new Exchange(TEST_DEX_CONFIGURATION); - await expect( - exchange.getUnsignedSwapTxFromAmountIn( - params.fromAddress, - params.inputToken, - params.outputToken, - newAmountFromString('100', USDC_TEST_TOKEN).value, - -5, - 2, - ), - ).rejects.toThrow(new InvalidSlippageError('Error: slippage percent must be greater than or equal to 0')); - }); + await expect( + exchange.getUnsignedSwapTxFromAmountIn( + params.fromAddress, + params.inputToken, + params.outputToken, + newAmountFromString('100', USDC_TEST_TOKEN).value, + -5, + 2, + ), + ).rejects.toThrow(new InvalidSlippageError('Error: slippage percent must be greater than or equal to 0')); }); }); }); diff --git a/packages/internal/generated-clients/src/multi-rollup/domain/collections-api.ts b/packages/internal/generated-clients/src/multi-rollup/domain/collections-api.ts index 9d4046f450..b85ee38532 100644 --- a/packages/internal/generated-clients/src/multi-rollup/domain/collections-api.ts +++ b/packages/internal/generated-clients/src/multi-rollup/domain/collections-api.ts @@ -73,7 +73,7 @@ export const CollectionsApiAxiosParamCreator = function (configuration?: Configu const localVarQueryParameter = {} as any; - + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -129,7 +129,7 @@ export const CollectionsApiAxiosParamCreator = function (configuration?: Configu } - + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -177,7 +177,7 @@ export const CollectionsApiAxiosParamCreator = function (configuration?: Configu } - + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -218,10 +218,10 @@ export const CollectionsApiAxiosParamCreator = function (configuration?: Configu const localVarQueryParameter = {} as any; // authentication ImmutableApiKey required - await setApiKeyToObject(localVarHeaderParameter, "x-immutable-api-Key", configuration) + await setApiKeyToObject(localVarHeaderParameter, "x-immutable-api-key", configuration) + - localVarHeaderParameter['Content-Type'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); diff --git a/packages/internal/generated-clients/src/multi-rollup/domain/metadata-api.ts b/packages/internal/generated-clients/src/multi-rollup/domain/metadata-api.ts index e0b6bdcb1a..cdaf2df82b 100644 --- a/packages/internal/generated-clients/src/multi-rollup/domain/metadata-api.ts +++ b/packages/internal/generated-clients/src/multi-rollup/domain/metadata-api.ts @@ -81,7 +81,7 @@ export const MetadataApiAxiosParamCreator = function (configuration?: Configurat const localVarQueryParameter = {} as any; - + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -136,7 +136,7 @@ export const MetadataApiAxiosParamCreator = function (configuration?: Configurat } - + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -177,10 +177,10 @@ export const MetadataApiAxiosParamCreator = function (configuration?: Configurat const localVarQueryParameter = {} as any; // authentication ImmutableApiKey required - await setApiKeyToObject(localVarHeaderParameter, "x-immutable-api-Key", configuration) + await setApiKeyToObject(localVarHeaderParameter, "x-immutable-api-key", configuration) + - localVarHeaderParameter['Content-Type'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -224,10 +224,10 @@ export const MetadataApiAxiosParamCreator = function (configuration?: Configurat const localVarQueryParameter = {} as any; // authentication ImmutableApiKey required - await setApiKeyToObject(localVarHeaderParameter, "x-immutable-api-Key", configuration) + await setApiKeyToObject(localVarHeaderParameter, "x-immutable-api-key", configuration) + - localVarHeaderParameter['Content-Type'] = 'application/json'; setSearchParams(localVarUrlObj, localVarQueryParameter); diff --git a/packages/passport/sdk-sample-app/README.md b/packages/passport/sdk-sample-app/README.md index 3ae05aa0c5..60f0014e98 100644 --- a/packages/passport/sdk-sample-app/README.md +++ b/packages/passport/sdk-sample-app/README.md @@ -3,14 +3,11 @@ ## Running Locally ```bash -# install deps & build the sdk at project root +# Install deps yarn -yarn build -# install deps & run the sample app -# cd packages/passport/sdk-sample-app -yarn -yarn dev +# Build the passport SDK and run the sample app +yarn workspace @imtbl/passport build && yarn workspace @imtbl/passport-sdk-sample-app dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. diff --git a/packages/passport/sdk-sample-app/src/components/imx/ImxWorkflow.tsx b/packages/passport/sdk-sample-app/src/components/imx/ImxWorkflow.tsx index 00eb0231f4..3b7c27bac8 100644 --- a/packages/passport/sdk-sample-app/src/components/imx/ImxWorkflow.tsx +++ b/packages/passport/sdk-sample-app/src/components/imx/ImxWorkflow.tsx @@ -18,29 +18,57 @@ function ImxWorkflow() { const [showTransfer, setShowTransfer] = useState(false); const [showOrder, setShowOrder] = useState(false); - const { addMessage, isLoading } = useStatusProvider(); + const { addMessage, isLoading, setIsLoading } = useStatusProvider(); const { connectImx, imxProvider } = usePassportProvider(); - const getAddress = useCallback(async () => { - const address = await imxProvider?.getAddress(); - addMessage('Get Address', address); - }, [addMessage, imxProvider]); + const ensureUserIsRegistered = useCallback(async (callback: Function) => { + setIsLoading(true); + try { + if (await imxProvider?.isRegisteredOffchain()) { + await callback(); + } else { + addMessage('Please call `registerOffchain` before calling this method'); + } + } finally { + setIsLoading(false); + } + }, [addMessage, imxProvider, setIsLoading]); - const handleBulkTransfer = () => { - setShowBulkTransfer(true); - }; + const getAddress = useCallback(async () => ( + ensureUserIsRegistered(async () => { + const address = await imxProvider?.getAddress(); + addMessage('Get Address', address); + }) + ), [addMessage, ensureUserIsRegistered, imxProvider]); - const handleTransfer = () => { - setShowTransfer(true); + const isRegisteredOffchain = async () => { + try { + setIsLoading(true); + const result = await imxProvider?.isRegisteredOffchain(); + addMessage('Is Registered Offchain', result); + } catch (err) { + addMessage('Is Registered Offchain', err); + } finally { + setIsLoading(false); + } }; - const handleTrade = () => { - setShowTrade(true); + const registerUser = async () => { + try { + setIsLoading(true); + const result = await imxProvider?.registerOffchain(); + addMessage('Register off chain', result); + } catch (err) { + addMessage('Register off chain', err); + } finally { + setIsLoading(false); + } }; - const handleOrder = useCallback(() => { - setShowOrder(true); - }, []); + const handleBulkTransfer = () => ensureUserIsRegistered(() => setShowBulkTransfer(true)); + const handleTransfer = () => ensureUserIsRegistered(() => setShowTransfer(true)); + const handleTrade = () => ensureUserIsRegistered(() => setShowTrade(true)); + const handleOrder = () => ensureUserIsRegistered(() => setShowOrder(true)); return ( @@ -113,6 +141,18 @@ function ImxWorkflow() { > Get Address + + Is Registered Offchain + + + Register User + )} diff --git a/packages/passport/sdk-sample-app/src/context/StatusProvider.tsx b/packages/passport/sdk-sample-app/src/context/StatusProvider.tsx index c248be84b4..229a164092 100644 --- a/packages/passport/sdk-sample-app/src/context/StatusProvider.tsx +++ b/packages/passport/sdk-sample-app/src/context/StatusProvider.tsx @@ -23,17 +23,19 @@ export function StatusProvider({ const addMessage = useCallback((operation: string, ...args: any[]) => { let messageString: string; - if (args[0] instanceof PassportError) { - messageString = `${args[0].type}: ${args[0].message}`; + if (!args?.length) { + messageString = operation; + } else if (args[0] instanceof PassportError) { + messageString = `${operation}: ${args[0].type} - ${args[0].message}`; } else { - messageString = args.map((arg) => { + messageString = `${operation}: ${args.map((arg) => { if (arg instanceof Error) { return arg.toString(); } return JSON.stringify(arg, null, 2); - }).join(': '); + }).join(' - ')}`; } - setMessages((prevMessages) => [...prevMessages, `${operation}: ${messageString}`]); + setMessages((prevMessages) => [...prevMessages, messageString]); }, []); const providerValues = useMemo(() => ({ diff --git a/packages/passport/sdk/src/authManager.ts b/packages/passport/sdk/src/authManager.ts index a37aec6975..d5e2e6e2e3 100644 --- a/packages/passport/sdk/src/authManager.ts +++ b/packages/passport/sdk/src/authManager.ts @@ -14,7 +14,7 @@ import { PassportErrorType, withPassportError } from './errors/passportError'; import { PassportMetadata, User, - DeviceCodeReponse, + DeviceCodeResponse, DeviceConnectResponse, DeviceTokenResponse, DeviceErrorResponse, @@ -106,9 +106,9 @@ export default class AuthManager { }; if (passport?.imx_eth_address) { user.imx = { - ethAddress: passport?.imx_eth_address, - starkAddress: passport?.imx_stark_address, - userAdminAddress: passport?.imx_user_admin_address, + ethAddress: passport.imx_eth_address, + starkAddress: passport.imx_stark_address, + userAdminAddress: passport.imx_user_admin_address, }; } if (passport?.zkevm_eth_address) { @@ -132,11 +132,11 @@ export default class AuthManager { nickname: idTokenPayload.nickname, }, }; - if (idTokenPayload?.passport?.imx_eth_address) { + if (idTokenPayload?.passport.imx_eth_address) { user.imx = { - ethAddress: idTokenPayload?.passport?.imx_eth_address, - starkAddress: idTokenPayload?.passport?.imx_stark_address, - userAdminAddress: idTokenPayload?.passport?.imx_user_admin_address, + ethAddress: idTokenPayload.passport.imx_eth_address, + starkAddress: idTokenPayload.passport.imx_stark_address, + userAdminAddress: idTokenPayload.passport.imx_user_admin_address, }; } if (idTokenPayload?.passport?.zkevm_eth_address) { @@ -168,7 +168,7 @@ export default class AuthManager { public async loginWithDeviceFlow(): Promise { return withPassportError(async () => { - const response = await axios.post( + const response = await axios.post( `${this.config.authenticationDomain}/oauth/device/code`, { client_id: this.config.oidcConfiguration.clientId, diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts index f0595227ad..8e2d5f54dd 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts @@ -14,19 +14,31 @@ import { UnsignedOrderRequest, UnsignedTransferRequest, } from '@imtbl/core-sdk'; -import { mockUserImx, testConfig } from '../test/mocks'; +import { Web3Provider } from '@ethersproject/providers'; +import registerPassportStarkEx from './workflows/registration'; +import { mockUserImx, testConfig, mockUser } from '../test/mocks'; import { PassportError, PassportErrorType } from '../errors/passportError'; import { PassportImxProvider } from './passportImxProvider'; import { - batchNftTransfer, cancelOrder, createOrder, createTrade, exchangeTransfer, transfer, + batchNftTransfer, + cancelOrder, + createOrder, + createTrade, + exchangeTransfer, + transfer, } from './workflows'; import { ConfirmationScreen } from '../confirmation'; import { PassportConfiguration } from '../config'; import { PassportEventMap, PassportEvents } from '../types'; import TypedEventEmitter from '../utils/typedEventEmitter'; import AuthManager from '../authManager'; +import MagicAdapter from '../magicAdapter'; +import { getStarkSigner } from './getStarkSigner'; +jest.mock('@ethersproject/providers'); jest.mock('./workflows'); +jest.mock('./workflows/registration'); +jest.mock('./getStarkSigner'); describe('PassportImxProvider', () => { afterEach(jest.resetAllMocks); @@ -52,15 +64,34 @@ describe('PassportImxProvider', () => { getAddress: jest.fn(), } as StarkSigner; + const mockEthSigner = { + signMessage: jest.fn(), + getAddress: jest.fn(), + }; + + const magicAdapterMock = { + login: jest.fn(), + }; + + const getSignerMock = jest.fn(); + let passportEventEmitter: TypedEventEmitter; beforeEach(() => { + jest.restoreAllMocks(); + getSignerMock.mockReturnValue(mockEthSigner); + (registerPassportStarkEx as jest.Mock).mockResolvedValue(null); passportEventEmitter = new TypedEventEmitter(); mockAuthManager.getUser.mockResolvedValue(mockUserImx); + // Signers + magicAdapterMock.login.mockResolvedValue({ getSigner: getSignerMock }); + (Web3Provider as unknown as jest.Mock).mockReturnValue({ getSigner: getSignerMock }); + (getStarkSigner as jest.Mock).mockResolvedValue(mockStarkSigner); + passportImxProvider = new PassportImxProvider({ authManager: mockAuthManager as unknown as AuthManager, - starkSigner: mockStarkSigner, + magicAdapter: magicAdapterMock as unknown as MagicAdapter, confirmationScreen, immutableXClient, config: testConfig, @@ -68,6 +99,46 @@ describe('PassportImxProvider', () => { }); }); + describe('async signer initialisation', () => { + it('initialises the eth and stark signers correctly', async () => { + // The promise is created in the constructor but not awaited until a method is called + await passportImxProvider.getAddress(); + + expect(magicAdapterMock.login).toHaveBeenCalledWith(mockUserImx.idToken); + expect(getStarkSigner).toHaveBeenCalledWith(mockEthSigner); + }); + + it('initialises the eth and stark signers only once', async () => { + await passportImxProvider.getAddress(); + await passportImxProvider.getAddress(); + await passportImxProvider.getAddress(); + + expect(magicAdapterMock.login).toHaveBeenCalledTimes(1); + expect(getStarkSigner).toHaveBeenCalledTimes(1); + }); + + it('re-throws the initialisation error when a method is called', async () => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + + mockAuthManager.getUser.mockResolvedValue(mockUserImx); + // Signers + magicAdapterMock.login.mockResolvedValue({}); + (getStarkSigner as jest.Mock).mockRejectedValue(new Error('error')); + + const pp = new PassportImxProvider({ + authManager: mockAuthManager as unknown as AuthManager, + magicAdapter: magicAdapterMock as unknown as MagicAdapter, + confirmationScreen, + immutableXClient, + config: testConfig, + passportEventEmitter: new TypedEventEmitter(), + }); + + await expect(pp.getAddress()).rejects.toThrow(new Error('error')); + }); + }); + describe('transfer', () => { it('calls transfer workflow', async () => { const returnValue = {} as CreateTransferResponseV1; @@ -90,27 +161,25 @@ describe('PassportImxProvider', () => { }); }); - describe('registerOffchain', () => { - it('should throw error', async () => { - expect(passportImxProvider.registerOffchain) - .toThrow( - new PassportError( - 'Operation not supported', - PassportErrorType.OPERATION_NOT_SUPPORTED_ERROR, - ), - ); + describe('isRegisteredOffchain', () => { + it('should return true when a user is registered', async () => { + const isRegistered = await passportImxProvider.isRegisteredOffchain(); + expect(isRegistered).toEqual(true); }); - }); - describe('isRegisteredOnchain', () => { - it('should throw error', async () => { - expect(passportImxProvider.isRegisteredOnchain) - .toThrow( - new PassportError( - 'Operation not supported', - PassportErrorType.OPERATION_NOT_SUPPORTED_ERROR, - ), - ); + it('should return false when a user is not registered', async () => { + mockAuthManager.getUser.mockResolvedValue({}); + const isRegistered = await passportImxProvider.isRegisteredOffchain(); + expect(isRegistered).toEqual(false); + }); + + it('should bubble up the error if user is not logged in', async () => { + mockAuthManager.getUser.mockResolvedValue(undefined); + + await expect(passportImxProvider.isRegisteredOffchain()).rejects.toThrow(new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + )); }); }); @@ -266,6 +335,26 @@ describe('PassportImxProvider', () => { }); }); + describe('registerOffChain', () => { + it('should register the user and update the provider instance user', async () => { + const magicProviderMock = {}; + + mockAuthManager.login.mockResolvedValue(mockUser); + magicAdapterMock.login.mockResolvedValue(magicProviderMock); + mockAuthManager.loginSilent.mockResolvedValue({ ...mockUser, imx: { ethAddress: '', starkAddress: '', userAdminAddress: '' } }); + + await passportImxProvider.registerOffchain(); + + expect(registerPassportStarkEx).toHaveBeenCalledWith({ + ethSigner: mockEthSigner, + starkSigner: mockStarkSigner, + usersApi: immutableXClient.usersApi, + }, mockUserImx.accessToken); + expect(mockAuthManager.loginSilent).toHaveBeenCalledTimes(1); + expect(mockAuthManager.loginSilent).toHaveBeenCalledWith({ forceRefresh: true }); + }); + }); + describe.each([ ['transfer' as const, {} as UnsignedTransferRequest], ['createOrder' as const, {} as UnsignedOrderRequest], diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.ts index 7f66593748..e96def964d 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProvider.ts @@ -12,6 +12,7 @@ import { NftTransferDetails, RegisterUserResponse, StarkSigner, + EthSigner, TokenAmount, UnsignedExchangeTransferRequest, UnsignedOrderRequest, @@ -19,9 +20,12 @@ import { } from '@imtbl/core-sdk'; import { ImmutableXClient } from '@imtbl/immutablex-client'; import { IMXProvider } from '@imtbl/provider'; +import AuthManager from 'authManager'; +import TypedEventEmitter from 'utils/typedEventEmitter'; +import { Web3Provider } from '@ethersproject/providers'; import GuardianClient from '../guardian/guardian'; import { - PassportEventMap, PassportEvents, User, UserImx, + PassportEventMap, PassportEvents, UserImx, User, IMXSigners, } from '../types'; import { PassportError, PassportErrorType } from '../errors/passportError'; import { @@ -29,63 +33,134 @@ import { } from './workflows'; import { ConfirmationScreen } from '../confirmation'; import { PassportConfiguration } from '../config'; -import TypedEventEmitter from '../utils/typedEventEmitter'; -import AuthManager from '../authManager'; +import registerOffchain from './workflows/registerOffchain'; +import MagicAdapter from '../magicAdapter'; +import { getStarkSigner } from './getStarkSigner'; -export interface PassportImxProviderInput { +export interface PassportImxProviderOptions { authManager: AuthManager; - starkSigner: StarkSigner; immutableXClient: ImmutableXClient; confirmationScreen: ConfirmationScreen; config: PassportConfiguration; passportEventEmitter: TypedEventEmitter; + magicAdapter: MagicAdapter; } -type AuthenticatedUserSigner = { +type AuthenticatedUserAndSigners = { + user: User; + starkSigner: StarkSigner; + ethSigner: EthSigner; +}; + +type RegisteredUserAndSigners = { user: UserImx; starkSigner: StarkSigner; + ethSigner: EthSigner; }; export class PassportImxProvider implements IMXProvider { protected readonly authManager: AuthManager; - protected starkSigner?: StarkSigner; - - protected readonly immutableXClient: ImmutableXClient; + private readonly immutableXClient: ImmutableXClient; protected readonly guardianClient: GuardianClient; + protected magicAdapter: MagicAdapter; + + /** + * This property is set during initialisation and stores the signers in a promise. + * This property is not meant to be accessed directly, but through the + * `getAuthenticatedUserAndSigners` method. + * @see getAuthenticatedUserAndSigners + */ + private signers: Promise | undefined; + + private signerInitialisationError: unknown | undefined; + constructor({ authManager, - starkSigner, immutableXClient, confirmationScreen, config, passportEventEmitter, - }: PassportImxProviderInput) { + magicAdapter, + }: PassportImxProviderOptions) { this.authManager = authManager; - this.starkSigner = starkSigner; this.immutableXClient = immutableXClient; this.guardianClient = new GuardianClient({ confirmationScreen, config, }); + this.magicAdapter = magicAdapter; + this.initialiseSigners(); passportEventEmitter.on(PassportEvents.LOGGED_OUT, this.handleLogout); } private handleLogout = (): void => { - this.starkSigner = undefined; + this.signers = undefined; }; - protected async getAuthenticatedUserSigner(): Promise { + /** + * This method is called by the constructor and asynchronously initialises the signers. + * The signers are stored in a promise so that they can be retrieved by the provider + * when needed. + * + * If an error is thrown during initialisation, it is stored in the `signerInitialisationError`, + * so that it doesn't result in an unhandled promise rejection. + * + * This error is thrown when the signers are requested through: + * @see getAuthenticatedUserAndSigners + * + */ + private async initialiseSigners(): Promise { + const generateSigners = async (): Promise => { + const user = await this.authManager.getUser(); + // The user will be present because the factory validates it + const magicRpcProvider = await this.magicAdapter.login(user!.idToken!); + const web3Provider = new Web3Provider(magicRpcProvider); + + const ethSigner = web3Provider.getSigner(); + const starkSigner = await getStarkSigner(ethSigner); + + return { ethSigner, starkSigner }; + }; + + // eslint-disable-next-line no-async-promise-executor + this.signers = new Promise(async (resolve) => { + try { + resolve(await generateSigners()); + } catch (err) { + // Capture and store the initialization error + this.signerInitialisationError = err; + resolve(undefined); + } + }); + } + + protected async getAuthenticatedUserAndSigners(): Promise { const user = await this.authManager.getUser(); - if (!user || this.starkSigner === undefined) { + if (!user || !this.signers) { throw new PassportError( 'User has been logged out', PassportErrorType.NOT_LOGGED_IN_ERROR, ); } + + const signers = await this.signers; + // Throw the stored error if the signers failed to initialise + if (typeof signers === 'undefined') { + if (typeof this.signerInitialisationError !== 'undefined') { + throw this.signerInitialisationError; + } + throw new Error('Signers failed to initialise'); + } + + return { user, ...signers }; + } + + protected async getRegisteredImxUserAndSigners(): Promise { + const { user, starkSigner, ethSigner } = await this.getAuthenticatedUserAndSigners(); const isUserImx = (oidcUser: User | null): oidcUser is UserImx => oidcUser?.imx !== undefined; if (!isUserImx(user)) { @@ -95,11 +170,11 @@ export class PassportImxProvider implements IMXProvider { ); } - return { user, starkSigner: this.starkSigner }; + return { user, starkSigner, ethSigner }; } async transfer(request: UnsignedTransferRequest): Promise { - const { user, starkSigner } = await this.getAuthenticatedUserSigner(); + const { user, starkSigner } = await this.getRegisteredImxUserAndSigners(); return transfer({ request, @@ -110,26 +185,24 @@ export class PassportImxProvider implements IMXProvider { }); } - // TODO: Remove once implemented - // eslint-disable-next-line class-methods-use-this - registerOffchain(): Promise { - throw new PassportError( - 'Operation not supported', - PassportErrorType.OPERATION_NOT_SUPPORTED_ERROR, + async registerOffchain(): Promise { + const { user, ethSigner, starkSigner } = await this.getAuthenticatedUserAndSigners(); + return await registerOffchain( + ethSigner, + starkSigner, + user, + this.authManager, + this.immutableXClient.usersApi, ); } - // TODO: Remove once implemented - // eslint-disable-next-line class-methods-use-this - isRegisteredOffchain(): Promise { - throw new PassportError( - 'Operation not supported', - PassportErrorType.OPERATION_NOT_SUPPORTED_ERROR, - ); + async isRegisteredOffchain(): Promise { + const { user } = await this.getAuthenticatedUserAndSigners(); + return !!user.imx; } // TODO: Remove once implemented - // eslint-disable-next-line class-methods-use-this + // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars isRegisteredOnchain(): Promise { throw new PassportError( 'Operation not supported', @@ -138,7 +211,7 @@ export class PassportImxProvider implements IMXProvider { } async createOrder(request: UnsignedOrderRequest): Promise { - const { user, starkSigner } = await this.getAuthenticatedUserSigner(); + const { user, starkSigner } = await this.getRegisteredImxUserAndSigners(); return createOrder({ request, @@ -152,7 +225,7 @@ export class PassportImxProvider implements IMXProvider { async cancelOrder( request: GetSignableCancelOrderRequest, ): Promise { - const { user, starkSigner } = await this.getAuthenticatedUserSigner(); + const { user, starkSigner } = await this.getRegisteredImxUserAndSigners(); return cancelOrder({ request, @@ -164,7 +237,7 @@ export class PassportImxProvider implements IMXProvider { } async createTrade(request: GetSignableTradeRequest): Promise { - const { user, starkSigner } = await this.getAuthenticatedUserSigner(); + const { user, starkSigner } = await this.getRegisteredImxUserAndSigners(); return createTrade({ request, @@ -178,7 +251,7 @@ export class PassportImxProvider implements IMXProvider { async batchNftTransfer( request: NftTransferDetails[], ): Promise { - const { user, starkSigner } = await this.getAuthenticatedUserSigner(); + const { user, starkSigner } = await this.getRegisteredImxUserAndSigners(); return batchNftTransfer({ request, @@ -192,7 +265,7 @@ export class PassportImxProvider implements IMXProvider { async exchangeTransfer( request: UnsignedExchangeTransferRequest, ): Promise { - const { user, starkSigner } = await this.getAuthenticatedUserSigner(); + const { user, starkSigner } = await this.getRegisteredImxUserAndSigners(); return exchangeTransfer({ request, @@ -235,8 +308,7 @@ export class PassportImxProvider implements IMXProvider { } async getAddress(): Promise { - const { user } = await this.getAuthenticatedUserSigner(); - + const { user } = await this.getRegisteredImxUserAndSigners(); return Promise.resolve(user.imx.ethAddress); } } diff --git a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.test.ts b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.test.ts index 2cfbc59726..5f52e2eba8 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.test.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.test.ts @@ -1,5 +1,4 @@ import { ImmutableXClient } from '@imtbl/immutablex-client'; -import { Web3Provider } from '@ethersproject/providers'; import { ConfirmationScreen } from '../confirmation'; import registerPassportStarkEx from './workflows/registration'; import { PassportImxProviderFactory } from './passportImxProviderFactory'; @@ -7,14 +6,11 @@ import MagicAdapter from '../magicAdapter'; import AuthManager from '../authManager'; import { PassportError, PassportErrorType } from '../errors/passportError'; import { PassportEventMap } from '../types'; -import { PassportImxProvider } from './passportImxProvider'; -import { getStarkSigner } from './getStarkSigner'; -import { mockUser, mockUserImx, testConfig } from '../test/mocks'; +import { mockUserImx, testConfig } from '../test/mocks'; import TypedEventEmitter from '../utils/typedEventEmitter'; +import { PassportImxProvider } from './passportImxProvider'; -jest.mock('@ethersproject/providers'); jest.mock('./workflows/registration'); -jest.mock('./getStarkSigner'); jest.mock('./passportImxProvider'); describe('PassportImxProviderFactory', () => { @@ -22,9 +18,7 @@ describe('PassportImxProviderFactory', () => { loginSilent: jest.fn(), login: jest.fn(), }; - const mockMagicAdapter = { - login: jest.fn(), - }; + const mockMagicAdapter = {}; const immutableXClient = { usersApi: {}, } as ImmutableXClient; @@ -40,18 +34,10 @@ describe('PassportImxProviderFactory', () => { passportEventEmitter, }); const mockPassportImxProvider = {}; - const mockEthSigner = {}; - const mockStarkSigner = {}; - const mockGetSigner = jest.fn(); beforeEach(() => { jest.restoreAllMocks(); - mockGetSigner.mockReturnValue(mockEthSigner); - (Web3Provider as unknown as jest.Mock).mockReturnValue({ - getSigner: mockGetSigner, - }); (registerPassportStarkEx as jest.Mock).mockResolvedValue(null); - (getStarkSigner as jest.Mock).mockResolvedValue(mockStarkSigner); (PassportImxProvider as jest.Mock).mockImplementation(() => mockPassportImxProvider); }); @@ -83,125 +69,24 @@ describe('PassportImxProviderFactory', () => { }); }); - describe('when the user has not registered', () => { - describe('when we exceed the number of attempts to obtain a user with the correct metadata', () => { - it('should throw an error', async () => { - const mockMagicProvider = {}; - - mockAuthManager.login.mockResolvedValue(mockUser); - mockMagicAdapter.login.mockResolvedValue(mockMagicProvider); - mockAuthManager.loginSilent.mockResolvedValueOnce(null); - mockAuthManager.loginSilent.mockResolvedValue(mockUser); - - await expect(() => passportImxProviderFactory.getProvider()).rejects.toThrow( - new PassportError( - 'Retry failed', - PassportErrorType.REFRESH_TOKEN_ERROR, - ), - ); - - expect(mockAuthManager.login).toHaveBeenCalledTimes(1); - expect(mockMagicAdapter.login).toHaveBeenCalledWith(mockUser.idToken); - expect(mockGetSigner).toHaveBeenCalledTimes(1); - expect(registerPassportStarkEx).toHaveBeenCalledWith({ - ethSigner: mockEthSigner, - starkSigner: mockStarkSigner, - usersApi: immutableXClient.usersApi, - }, mockUser.accessToken); - expect(mockAuthManager.loginSilent).toHaveBeenCalledTimes(5); - expect(mockAuthManager.loginSilent).toHaveBeenNthCalledWith(1); - expect(mockAuthManager.loginSilent).toHaveBeenNthCalledWith(2, { forceRefresh: true }); - expect(mockAuthManager.loginSilent).toHaveBeenNthCalledWith(3, { forceRefresh: true }); - expect(mockAuthManager.loginSilent).toHaveBeenNthCalledWith(4, { forceRefresh: true }); - expect(mockAuthManager.loginSilent).toHaveBeenCalledWith({ forceRefresh: true }); - }); - }); - - describe('when registration is successful', () => { - it('should register the user and return a PassportImxProvider instance', async () => { - const mockMagicProvider = {}; - - mockAuthManager.login.mockResolvedValue(mockUser); - mockMagicAdapter.login.mockResolvedValue(mockMagicProvider); - mockAuthManager.loginSilent.mockResolvedValueOnce(null); - mockAuthManager.loginSilent.mockResolvedValue(mockUserImx); - - const result = await passportImxProviderFactory.getProvider(); - - expect(result).toBe(mockPassportImxProvider); - expect(mockAuthManager.login).toHaveBeenCalledTimes(1); - expect(mockMagicAdapter.login).toHaveBeenCalledWith(mockUserImx.idToken); - expect(mockGetSigner).toHaveBeenCalledTimes(1); - expect(registerPassportStarkEx).toHaveBeenCalledWith({ - ethSigner: mockEthSigner, - starkSigner: mockStarkSigner, - usersApi: immutableXClient.usersApi, - }, mockUserImx.accessToken); - expect(mockAuthManager.loginSilent).toHaveBeenCalledTimes(2); - expect(mockAuthManager.loginSilent).toHaveBeenCalledWith({ forceRefresh: true }); - expect(PassportImxProvider).toHaveBeenCalledWith({ - authManager: mockAuthManager, - starkSigner: mockStarkSigner, - immutableXClient, - config, - confirmationScreen, - passportEventEmitter, - }); - }); - }); - }); - - describe('when the user has registered previously', () => { - it('should return a PassportImxProvider instance', async () => { - const mockMagicProvider = {}; - - mockAuthManager.login.mockResolvedValue(mockUserImx); - mockMagicAdapter.login.mockResolvedValue(mockMagicProvider); - mockAuthManager.loginSilent.mockResolvedValue(null); - mockAuthManager.login.mockResolvedValue(mockUserImx); - - const result = await passportImxProviderFactory.getProvider(); - - expect(result).toBe(mockPassportImxProvider); - expect(mockAuthManager.loginSilent).toHaveBeenCalledTimes(1); - expect(mockAuthManager.login).toHaveBeenCalledTimes(1); - expect(mockMagicAdapter.login).toHaveBeenCalledWith(mockUserImx.idToken); - expect(mockGetSigner).toHaveBeenCalledTimes(1); - expect(registerPassportStarkEx).not.toHaveBeenCalled(); - expect(PassportImxProvider).toHaveBeenCalledWith({ - authManager: mockAuthManager, - starkSigner: mockStarkSigner, - immutableXClient, - config, - confirmationScreen, - passportEventEmitter, - }); - }); - - it('should return a PassportImxProvider instance if slientLogin throws error', async () => { - const mockMagicProvider = {}; - - mockAuthManager.login.mockResolvedValue(mockUserImx); - mockMagicAdapter.login.mockResolvedValue(mockMagicProvider); - mockAuthManager.loginSilent.mockRejectedValue(new Error('error')); - mockAuthManager.login.mockResolvedValue(mockUserImx); - - const result = await passportImxProviderFactory.getProvider(); - - expect(result).toBe(mockPassportImxProvider); - expect(mockAuthManager.loginSilent).toHaveBeenCalledTimes(1); - expect(mockAuthManager.login).toHaveBeenCalledTimes(1); - expect(mockMagicAdapter.login).toHaveBeenCalledWith(mockUserImx.idToken); - expect(mockGetSigner).toHaveBeenCalledTimes(1); - expect(registerPassportStarkEx).not.toHaveBeenCalled(); - expect(PassportImxProvider).toHaveBeenCalledWith({ - authManager: mockAuthManager, - starkSigner: mockStarkSigner, - immutableXClient, - config, - confirmationScreen, - passportEventEmitter, - }); + it('should return a PassportImxProvider instance if silentLogin throws error', async () => { + mockAuthManager.login.mockResolvedValue(mockUserImx); + mockAuthManager.loginSilent.mockRejectedValue(new Error('error')); + mockAuthManager.login.mockResolvedValue(mockUserImx); + + const result = await passportImxProviderFactory.getProvider(); + + expect(result).toBe(mockPassportImxProvider); + expect(mockAuthManager.loginSilent).toHaveBeenCalledTimes(1); + expect(mockAuthManager.login).toHaveBeenCalledTimes(1); + expect(registerPassportStarkEx).not.toHaveBeenCalled(); + expect(PassportImxProvider).toHaveBeenCalledWith({ + magicAdapter: mockMagicAdapter, + authManager: mockAuthManager, + immutableXClient, + config, + confirmationScreen, + passportEventEmitter, }); }); }); diff --git a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts index c44b58bc84..90aa2c2683 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts @@ -1,19 +1,17 @@ -import { EthSigner, StarkSigner } from '@imtbl/core-sdk'; import { ImmutableXClient } from '@imtbl/immutablex-client'; -import { Web3Provider } from '@ethersproject/providers'; -import registerPassportStarkEx from './workflows/registration'; -import { retryWithDelay } from '../network/retry'; -import { PassportError, PassportErrorType, withPassportError } from '../errors/passportError'; +import { IMXProvider } from '@imtbl/provider'; +import { PassportError, PassportErrorType } from '../errors/passportError'; import { PassportConfiguration } from '../config'; import AuthManager from '../authManager'; import { ConfirmationScreen } from '../confirmation'; import MagicAdapter from '../magicAdapter'; import { - DeviceTokenResponse, PassportEventMap, User, UserImx, + DeviceTokenResponse, + PassportEventMap, + User, } from '../types'; -import { PassportImxProvider } from './passportImxProvider'; -import { getStarkSigner } from './getStarkSigner'; import TypedEventEmitter from '../utils/typedEventEmitter'; +import { PassportImxProvider } from './passportImxProvider'; export type PassportImxProviderFactoryInput = { authManager: AuthManager; @@ -53,7 +51,7 @@ export class PassportImxProviderFactory { this.passportEventEmitter = passportEventEmitter; } - public async getProvider(): Promise { + public async getProvider(): Promise { let user = null; try { user = await this.authManager.loginSilent(); @@ -67,7 +65,7 @@ export class PassportImxProviderFactory { return this.createProviderInstance(user); } - public async getProviderSilent(): Promise { + public async getProviderSilent(): Promise { const user = await this.authManager.loginSilent(); if (!user) { return null; @@ -80,17 +78,17 @@ export class PassportImxProviderFactory { deviceCode: string, interval: number, timeoutMs?: number, - ): Promise { + ): Promise { const user = await this.authManager.connectImxDeviceFlow(deviceCode, interval, timeoutMs); return this.createProviderInstance(user); } - public async getProviderWithPKCEFlow(authorizationCode: string, state: string): Promise { + public async getProviderWithPKCEFlow(authorizationCode: string, state: string): Promise { const user = await this.authManager.connectImxPKCEFlow(authorizationCode, state); return this.createProviderInstance(user); } - public async getProviderWithCredentials(tokenResponse: DeviceTokenResponse): Promise { + public async getProviderWithCredentials(tokenResponse: DeviceTokenResponse): Promise { const user = await this.authManager.connectImxWithCredentials(tokenResponse); if (!user) { return null; @@ -99,7 +97,7 @@ export class PassportImxProviderFactory { return this.createProviderInstance(user); } - private async createProviderInstance(user: User): Promise { + private async createProviderInstance(user: User): Promise { if (!user.idToken) { throw new PassportError( 'Failed to initialise', @@ -107,57 +105,13 @@ export class PassportImxProviderFactory { ); } - const magicRpcProvider = await this.magicAdapter.login(user.idToken); - const web3Provider = new Web3Provider( - magicRpcProvider, - ); - const ethSigner = web3Provider.getSigner(); - const starkSigner = await getStarkSigner(ethSigner); - - if (!user.imx?.ethAddress) { - await this.registerStarkEx(ethSigner, starkSigner, user.accessToken); - return new PassportImxProvider({ - authManager: this.authManager, - starkSigner, - immutableXClient: this.immutableXClient, - confirmationScreen: this.confirmationScreen, - config: this.config, - passportEventEmitter: this.passportEventEmitter, - }); - } - return new PassportImxProvider({ + config: this.config, authManager: this.authManager, - starkSigner, immutableXClient: this.immutableXClient, confirmationScreen: this.confirmationScreen, - config: this.config, passportEventEmitter: this.passportEventEmitter, + magicAdapter: this.magicAdapter, }); } - - private async registerStarkEx(userAdminKeySigner: EthSigner, starkSigner: StarkSigner, jwt: string) { - return withPassportError(async () => { - await registerPassportStarkEx( - { - ethSigner: userAdminKeySigner, - starkSigner, - usersApi: this.immutableXClient.usersApi, - }, - jwt, - ); - - // User metadata is updated asynchronously. Poll userinfo endpoint until it is updated. - const updatedUser = await retryWithDelay(async () => { - const user = await this.authManager.loginSilent({ forceRefresh: true }); // force refresh to get updated user info - const metadataExists = !!user?.imx; - if (metadataExists) { - return user; - } - return Promise.reject(new Error('user wallet addresses not exist')); - }); - - return updatedUser as UserImx; - }, PassportErrorType.REFRESH_TOKEN_ERROR); - } } diff --git a/packages/passport/sdk/src/starkEx/workflows/registerOffchain.test.ts b/packages/passport/sdk/src/starkEx/workflows/registerOffchain.test.ts new file mode 100644 index 0000000000..b8357aa10b --- /dev/null +++ b/packages/passport/sdk/src/starkEx/workflows/registerOffchain.test.ts @@ -0,0 +1,113 @@ +import { ImmutableXClient } from '@imtbl/immutablex-client'; +import { Web3Provider } from '@ethersproject/providers'; +import { Signer } from 'ethers'; +import AuthManager from 'authManager'; +import { mockUserImx } from 'test/mocks'; +import { AxiosError } from 'axios'; +import registerPassportStarkEx from './registration'; +import { PassportError, PassportErrorType } from '../../errors/passportError'; +import registerOffchain from './registerOffchain'; + +jest.mock('@ethersproject/providers'); +jest.mock('./registration'); + +const mockGetSigner = jest.fn(); + +const mockLogin = jest.fn(); + +const mockLoginSilent = jest.fn(); + +const mockAuthManager = { + loginSilent: mockLoginSilent, + login: mockLogin, +} as unknown as AuthManager; + +const mockImmutableXClient = { + usersApi: {}, +} as ImmutableXClient; + +const mockEthSigner = { getAddress: jest.fn() } as unknown as Signer; + +const mockStarkSigner = { getAddress: jest.fn() } as unknown as Signer; + +const mockReturnHash = '0x123'; + +mockGetSigner.mockReturnValue(mockEthSigner); +(Web3Provider as unknown as jest.Mock).mockReturnValue({ + getSigner: mockGetSigner, +}); + +(registerPassportStarkEx as jest.Mock).mockResolvedValue(mockReturnHash); + +describe('registerOffchain', () => { + describe('when we exceed the number of attempts to obtain a user with the correct metadata', () => { + it('should throw an error', async () => { + await (expect(() => registerOffchain( + mockEthSigner, + mockStarkSigner, + mockUserImx, + mockAuthManager, + mockImmutableXClient.usersApi, + )).rejects.toThrow(new PassportError( + 'Retry failed', + PassportErrorType.REFRESH_TOKEN_ERROR, + ))); + + expect(registerPassportStarkEx).toHaveBeenCalledWith( + { ethSigner: mockEthSigner, starkSigner: mockStarkSigner, usersApi: mockImmutableXClient.usersApi }, + mockUserImx.accessToken, + ); + + expect(mockAuthManager.loginSilent).toHaveBeenCalledTimes(4); + expect(mockAuthManager.loginSilent).toHaveBeenNthCalledWith(1, { forceRefresh: true }); + expect(mockAuthManager.loginSilent).toHaveBeenNthCalledWith(2, { forceRefresh: true }); + expect(mockAuthManager.loginSilent).toHaveBeenNthCalledWith(3, { forceRefresh: true }); + expect(mockAuthManager.loginSilent).toHaveBeenCalledWith({ forceRefresh: true }); + }); + }); + + describe('when registration is successful', () => { + it('should register the user and return the transaction hash as a string', async () => { + mockLoginSilent.mockResolvedValue(mockUserImx); + + const txHash = await registerOffchain( + mockEthSigner, + mockStarkSigner, + mockUserImx, + mockAuthManager, + mockImmutableXClient.usersApi, + ); + + expect(txHash).toEqual(mockReturnHash); + expect(registerPassportStarkEx).toHaveBeenCalledWith({ + ethSigner: mockEthSigner, + starkSigner: mockStarkSigner, + usersApi: mockImmutableXClient.usersApi, + }, mockUserImx.accessToken); + expect(mockAuthManager.loginSilent).toHaveBeenCalledTimes(1); + expect(mockAuthManager.loginSilent).toHaveBeenCalledWith({ forceRefresh: true }); + }); + + describe('when registration fails due to a 409 conflict', () => { + it('should refresh the user to get the updated token', async () => { + // create axios error with status 409 + const err = new AxiosError('User already registered'); + err.status = 409; + + (registerPassportStarkEx as jest.Mock).mockRejectedValue(err); + mockLoginSilent.mockResolvedValue(mockUserImx); + + await registerOffchain( + mockEthSigner, + mockStarkSigner, + mockUserImx, + mockAuthManager, + mockImmutableXClient.usersApi, + ); + + expect(mockAuthManager.loginSilent).toHaveBeenCalledTimes(1); + expect(mockAuthManager.loginSilent).toHaveBeenCalledWith({ forceRefresh: true }); + }); + }); + }); +}); diff --git a/packages/passport/sdk/src/starkEx/workflows/registerOffchain.ts b/packages/passport/sdk/src/starkEx/workflows/registerOffchain.ts new file mode 100644 index 0000000000..28d8dc9117 --- /dev/null +++ b/packages/passport/sdk/src/starkEx/workflows/registerOffchain.ts @@ -0,0 +1,51 @@ +import { + EthSigner, RegisterUserResponse, StarkSigner, UsersApi, +} from '@imtbl/core-sdk'; +import AuthManager from 'authManager'; +import { PassportErrorType, withPassportError } from 'errors/passportError'; +import { retryWithDelay } from 'network/retry'; +import { User } from 'types'; +import axios from 'axios'; +import registerPassportStarkEx from './registration'; + +async function forceUserRefresh(authManager: AuthManager) { + // User metadata is updated asynchronously. Poll userinfo endpoint until it is updated. + await retryWithDelay(async () => { + const user = await authManager.loginSilent({ forceRefresh: true }); // force refresh to get updated user info + if (user?.imx) return user; + + return Promise.reject(new Error('user wallet addresses not exist')); + }); +} + +export default async function registerOffchain( + userAdminKeySigner: EthSigner, + starkSigner: StarkSigner, + unregisteredUser: User, + authManager: AuthManager, + usersApi: UsersApi, +) { + return withPassportError(async () => { + try { + const response = await registerPassportStarkEx( + { + ethSigner: userAdminKeySigner, + starkSigner, + usersApi, + }, + unregisteredUser.accessToken, + ); + await forceUserRefresh(authManager); + + return response; + } catch (err: any) { + if (axios.isAxiosError(err) && err.status === 409) { + // The user already registered, but the user token is not updated yet. + await forceUserRefresh(authManager); + return { tx_hash: '' }; + } + + throw err; + } + }, PassportErrorType.USER_REGISTRATION_ERROR); +} diff --git a/packages/passport/sdk/src/starkEx/workflows/registration.test.ts b/packages/passport/sdk/src/starkEx/workflows/registration.test.ts index 558c41bc71..d28c660efe 100644 --- a/packages/passport/sdk/src/starkEx/workflows/registration.test.ts +++ b/packages/passport/sdk/src/starkEx/workflows/registration.test.ts @@ -1,7 +1,6 @@ import registerPassport, { RegisterPassportParams } from './registration'; -import { PassportError, PassportErrorType } from '../../errors/passportError'; -describe('registerPassportWorkflow', () => { +describe('registration', () => { const requestBody = { ether_key: '0x232', stark_key: '0x567', @@ -11,10 +10,11 @@ describe('registerPassportWorkflow', () => { const mockToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ'; it('registerPassportWorkflow successfully called api client to register passport user', async () => { + const transactionHash = 'a1b2c3'; const mockUserApi = { registerPassportUser: jest .fn() - .mockResolvedValue({ statusText: 'No Content' }), + .mockResolvedValue({ data: { tx_hash: transactionHash } }), getSignableRegistrationOffchain: jest.fn().mockReturnValue({ data: { payload_hash: '0x34', @@ -38,7 +38,7 @@ describe('registerPassportWorkflow', () => { const res = await registerPassport(request, mockToken); - expect(res).toEqual('No Content'); + expect(res).toEqual({ tx_hash: transactionHash }); expect(mockStarkSigner.signMessage).toHaveBeenCalled(); expect(mockEthSigner.signMessage).toHaveBeenCalled(); expect(mockUserApi.registerPassportUser).toHaveBeenCalledWith({ @@ -50,9 +50,10 @@ describe('registerPassportWorkflow', () => { }, }); }); - it('registerPassportWorkflow failed to call api client to register passport user', async () => { + + it('throws an error if the API returns an error', async () => { const mockUserApi = { - registerPassportUser: jest.fn().mockRejectedValue('error'), + registerPassportUser: jest.fn().mockRejectedValue(new Error('error')), getSignableRegistrationOffchain: jest.fn().mockReturnValue({ data: { payload_hash: '0x34', @@ -75,10 +76,7 @@ describe('registerPassportWorkflow', () => { }; await expect(registerPassport(request, mockToken)).rejects.toThrow( - new PassportError( - '', - PassportErrorType.USER_REGISTRATION_ERROR, - ), + new Error('error'), ); expect(mockStarkSigner.signMessage).toHaveBeenCalled(); diff --git a/packages/passport/sdk/src/starkEx/workflows/registration.ts b/packages/passport/sdk/src/starkEx/workflows/registration.ts index 52823029d3..984f83e741 100644 --- a/packages/passport/sdk/src/starkEx/workflows/registration.ts +++ b/packages/passport/sdk/src/starkEx/workflows/registration.ts @@ -1,6 +1,5 @@ import { signRaw } from '@imtbl/toolkit'; -import { UsersApi, WalletConnection } from '@imtbl/core-sdk'; -import { PassportErrorType, withPassportError } from '../../errors/passportError'; +import { RegisterUserResponse, UsersApi, WalletConnection } from '@imtbl/core-sdk'; export type RegisterPassportParams = WalletConnection & { usersApi: UsersApi; @@ -9,35 +8,33 @@ export type RegisterPassportParams = WalletConnection & { export default async function registerPassport( { ethSigner, starkSigner, usersApi }: RegisterPassportParams, authorization: string, -): Promise { - return withPassportError(async () => { - const [userAddress, starkPublicKey] = await Promise.all([ - ethSigner.getAddress(), - starkSigner.getAddress(), - ]); +): Promise { + const [userAddress, starkPublicKey] = await Promise.all([ + ethSigner.getAddress(), + starkSigner.getAddress(), + ]); - const signableResult = await usersApi.getSignableRegistrationOffchain({ - getSignableRegistrationRequest: { - ether_key: userAddress, - stark_key: starkPublicKey, - }, - }); + const signableResult = await usersApi.getSignableRegistrationOffchain({ + getSignableRegistrationRequest: { + ether_key: userAddress, + stark_key: starkPublicKey, + }, + }); - const { signable_message: signableMessage, payload_hash: payloadHash } = signableResult.data; - const [ethSignature, starkSignature] = await Promise.all([ - signRaw(signableMessage, ethSigner), - starkSigner.signMessage(payloadHash), - ]); + const { signable_message: signableMessage, payload_hash: payloadHash } = signableResult.data; + const [ethSignature, starkSignature] = await Promise.all([ + signRaw(signableMessage, ethSigner), + starkSigner.signMessage(payloadHash), + ]); - const response = await usersApi.registerPassportUser({ - authorization: `Bearer ${authorization}`, - registerPassportUserRequest: { - eth_signature: ethSignature, - ether_key: userAddress, - stark_signature: starkSignature, - stark_key: starkPublicKey, - }, - }); - return response.statusText; - }, PassportErrorType.USER_REGISTRATION_ERROR); + const response = await usersApi.registerPassportUser({ + authorization: `Bearer ${authorization}`, + registerPassportUserRequest: { + eth_signature: ethSignature, + ether_key: userAddress, + stark_signature: starkSignature, + stark_key: starkPublicKey, + }, + }); + return response.data as RegisterUserResponse; } diff --git a/packages/passport/sdk/src/types.ts b/packages/passport/sdk/src/types.ts index 8b9a58253a..9d0e419149 100644 --- a/packages/passport/sdk/src/types.ts +++ b/packages/passport/sdk/src/types.ts @@ -1,5 +1,6 @@ import { ModuleConfiguration } from '@imtbl/config'; import { ImmutableXClient } from '@imtbl/immutablex-client'; +import { EthSigner, StarkSigner } from '@imtbl/core-sdk'; export enum PassportEvents { LOGGED_OUT = 'loggedOut', @@ -92,7 +93,7 @@ export type DeviceConnectResponse = { interval: number; }; -export type DeviceCodeReponse = { +export type DeviceCodeResponse = { device_code: string; user_code: string; verification_uri: string; @@ -131,3 +132,8 @@ export type PKCEData = { state: string, verifier: string }; + +export type IMXSigners = { + starkSigner: StarkSigner, + ethSigner: EthSigner; +}; diff --git a/packages/passport/sdk/src/utils/lazyLoad.ts b/packages/passport/sdk/src/utils/lazyLoad.ts index a4476b6f75..2c22dfeec6 100644 --- a/packages/passport/sdk/src/utils/lazyLoad.ts +++ b/packages/passport/sdk/src/utils/lazyLoad.ts @@ -1,8 +1,9 @@ -export const lazyLoad = (promiseToAwait: () => Promise, initialiseFunction: () => T): Promise => ( - promiseToAwait().then(initialiseFunction) -); +export const lazyLoad = ( + promiseToAwait: () => Promise, + initialiseFunction: (arg: Y) => Promise | T, +): Promise => promiseToAwait().then(initialiseFunction); -export const lazyDocumentReady = (initialiseFunction: () => T): Promise => { +export const lazyDocumentReady = (initialiseFunction: () => Promise | T): Promise => { const documentReadyPromise = () => new Promise((resolve) => { if (window.document.readyState === 'complete') { resolve(); diff --git a/sdk/README.md b/sdk/README.md index 909d01e56b..70a6bb12e3 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -27,3 +27,150 @@ npm install @imtbl/sdk # or yarn add @imtbl/sdk ``` + +## Changelog + +### [0.29.0] - 13-11-2023 + +#### Changed (breaking) + +##### The `connectImx` method no longer registers users on Immutable X + +This method was previously responsible for automatically [registering a user](https://docs.immutable.com/docs/x/how-to-register-users/) +the first time they connect to Immutable X. + +###### Before + +```ts +const passport = new Passport(...); +const provider = await passport.connectImx(); +``` + +###### After + +Passport now allows you to manage the registration process yourself through the `isRegisteredOffchain` and +`registerOffchain` functions. + +This gives you the advantage of potentially displaying a custom UI informing the user that they’re being registered, +as opposed to the previous experience where the user was waiting until the `connectImx` step completed all the steps. + +```ts +const passport = new Passport(...); +const provider = await passport.connectImx(); + +if (!await provider.isRegisteredOffchain()) { + // Potentially inform the user the user that are about to be + // registered on Immutable X + await provider.registerOffchain(); +} + +// The user's wallet is registered, they are now ready to use Immutable X +const response = await provider.transfer(...); +``` + +##### Passport is now a client-side only module + +This change is only relevant if you are using server-side rendering (SSR). + +Passport has a dependency on browser-specific primitives, such as the `window` object, and therefore is intended to be +used in a client-side environment only. + +Depending on your framework, you may need to update how you were instantiating the Passport instance to ensure +this happens on the client. + +### [0.28.2] - 30-10-2023 + +#### Added + +We have added a new `login` method that allows you to authenticate users without necessarily instantiating their wallets. + +##### Before + +Previously, you had to authenticate users as follows: + +```ts +const passport = new Passport(...); +const provider = await passport.connectImx(); +``` + +The `connectImx` function performed the following tasks under the hood: + +- Completed the OIDC authentication flow to log the user in +- Instantiated their wallet based on their identity +- Registered the user on the Immutable X ecosystem + +Although this simple API was simple and convenient, we have decided to decouple it into explicit methods based on customer feedback to provide you with more flexibility. + +##### After + +From now on, you can authenticate users as follows: + +```ts +const passport = new Passport(...); +const user = await passport.login(); +``` + +The login method only authenticates the user. This allows you to separate the authentication process from wallet +instantiation and the registration step, giving you greater control and flexibility in managing user +authentication within your application. + +This is especially useful if your application only uses Passport for authentication and does not leverage +the wallet component. + +However, if you want to instantiate the wallet, you can do so as follows: + +```ts +const passport = new Passport(...); +const user = await passport.login(); // Optional + +const provider = await passport.connectImx(); +``` + +Depending on your application, you may decide to only log in the user when they click on “Sign in with Passport” +using `login`, and instantiate the wallet using `connectImx` in the background, ensuring a seamless +and user-friendly experience. + +Note that the `connectImx` method will attempt to log the user in automatically if you haven’t explicitly called login +before connecting to Immutable X. + +The following code will have the same outcome as the snippet above, but it's important to note that the `Promise` +returned by `connectImx` will not resolve until the user has successfully logged in and their wallet has been +instantiated, potentially leading to a less streamlined user experience. + +```ts +const passport = new Passport(...); +const provider = await passport.connectImx(); +``` + +Find more information about authenticating users in the [Passport Login documentation](https://docs.immutable.com/docs/x/passport/identity/login#1-trigger-the-login-process). + +#### Deprecated + +Following the introduction of the `login` method, we have deprecated the `connectImxSilent` method. + +This method was previously used for [rehydrating the session of previously authenticated users](https://docs.immutable.com/docs/x/passport/identity/login/#3-maintaining-the-login-status) +on page reloads. + +##### Before + +```ts +// On page load, attempt to re-authenticate the user without propting them to +// sign in based on their cached session and initialise the wallet. +const provider = await passport.connectImxSilent(); + +if (!provider) { + // The user session couldn't be recovered. The user will have to explicitly + // sign in again by clicking a button that trigger passport.connectImx() +} +``` + +##### After +```ts +// On page load, attempt to re-authenticate the user without propting them to +// sign in based on their cached session. +const user = await passport.login({ useCachedSession: true }); +if (user) { + // The user session is still valid, we can now initialise the wallet + const provider = await passport.connectImx(); +} +```