diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 57a6331d731..31dd20e1a5b 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** Replace `startQuotePolling()`/`stopQuotePolling()` with `fetchQuotesForSelection()` — quotes are now fetched once per call instead of polling on a 15-second interval ([#7999](https://github.com/MetaMask/core/pull/7999)) + +### Removed + +- Remove `stopQuotePolling()` method (no interval to stop) ([#7999](https://github.com/MetaMask/core/pull/7999)) +- Remove internal polling restart logic (`#restartPollingIfActive`) from `setSelectedProvider`, `setSelectedToken`, and `setSelectedPaymentMethod` ([#7999](https://github.com/MetaMask/core/pull/7999)) + ## [9.0.0] ### Added diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index c9b5ae3e71e..f45c7176840 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -4714,19 +4714,11 @@ describe('RampsController', () => { }); }); - describe('startQuotePolling', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - + describe('fetchQuotesForSelection', () => { it('throws error when region is not set', async () => { await withController(({ controller }) => { expect(() => - controller.startQuotePolling({ + controller.fetchQuotesForSelection({ walletAddress: '0x1234567890abcdef1234567890abcdef12345678', amount: 100, }), @@ -4747,12 +4739,12 @@ describe('RampsController', () => { }, ({ controller }) => { expect(() => - controller.startQuotePolling({ + controller.fetchQuotesForSelection({ walletAddress: '0x1234567890abcdef1234567890abcdef12345678', amount: 100, }), ).toThrow( - 'Token is required. Cannot start quote polling without a selected token.', + 'Token is required. Cannot fetch quotes without a selected token.', ); }, ); @@ -4781,18 +4773,18 @@ describe('RampsController', () => { }, ({ controller }) => { expect(() => - controller.startQuotePolling({ + controller.fetchQuotesForSelection({ walletAddress: '0x1234567890abcdef1234567890abcdef12345678', amount: 100, }), ).toThrow( - 'Provider is required. Cannot start quote polling without a selected provider.', + 'Provider is required. Cannot fetch quotes without a selected provider.', ); }, ); }); - it('returns early without starting polling when payment method is not selected', async () => { + it('returns early without fetching when payment method is not selected', async () => { await withController( { options: { @@ -4829,7 +4821,7 @@ describe('RampsController', () => { }, ({ controller }) => { expect(() => - controller.startQuotePolling({ + controller.fetchQuotesForSelection({ walletAddress: '0x1234567890abcdef1234567890abcdef12345678', amount: 100, }), @@ -4840,7 +4832,7 @@ describe('RampsController', () => { ); }); - it('fetches quotes immediately and sets up 15-second polling', async () => { + it('fetches quotes once and auto-selects single result', async () => { const mockQuotesResponse: QuotesResponse = { success: [ { @@ -4919,12 +4911,11 @@ describe('RampsController', () => { }, ); - controller.startQuotePolling({ + controller.fetchQuotesForSelection({ walletAddress: '0x1234567890abcdef1234567890abcdef12345678', amount: 100, }); - // Give promises time to resolve (getQuotes + .then callback) for (let i = 0; i < 10; i++) { await Promise.resolve(); } @@ -4933,24 +4924,6 @@ describe('RampsController', () => { expect(controller.state.quotes.selected).toStrictEqual( mockQuotesResponse.success[0], ); - - // Advance 15 seconds - jest.advanceTimersByTime(15000); - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - expect(callCount).toBe(2); - - // Advance another 15 seconds - jest.advanceTimersByTime(15000); - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - expect(callCount).toBe(3); - - controller.stopQuotePolling(); }, ); }); @@ -5030,12 +5003,11 @@ describe('RampsController', () => { async () => mockQuotesResponse, ); - controller.startQuotePolling({ + controller.fetchQuotesForSelection({ walletAddress: '0x1234567890abcdef1234567890abcdef12345678', amount: 100, }); - // Need multiple Promise.resolve() to flush microtask queue for (let i = 0; i < 10; i++) { await Promise.resolve(); } @@ -5043,8 +5015,6 @@ describe('RampsController', () => { expect(controller.state.quotes.selected).toStrictEqual( mockQuotesResponse.success[0], ); - - controller.stopQuotePolling(); }, ); }); @@ -5135,22 +5105,18 @@ describe('RampsController', () => { }, ); - controller.startQuotePolling({ + controller.fetchQuotesForSelection({ walletAddress: '0x1234567890abcdef1234567890abcdef12345678', amount: 100, }); - // Need multiple Promise.resolve() to flush microtask queue for (let i = 0; i < 10; i++) { await Promise.resolve(); } - // Verify only the selected payment method is passed, not all available expect(capturedPaymentMethods).toStrictEqual([ '/payments/bank-transfer', ]); - - controller.stopQuotePolling(); }, ); }); @@ -5248,7 +5214,7 @@ describe('RampsController', () => { const initialSelection = controller.state.quotes.selected; - controller.startQuotePolling({ + controller.fetchQuotesForSelection({ walletAddress: '0x1234567890abcdef1234567890abcdef12345678', amount: 100, }); @@ -5259,8 +5225,6 @@ describe('RampsController', () => { expect(controller.state.quotes.selected).toStrictEqual( initialSelection, ); - - controller.stopQuotePolling(); }, ); }); @@ -5363,7 +5327,7 @@ describe('RampsController', () => { '0.05', ); - controller.startQuotePolling({ + controller.fetchQuotesForSelection({ walletAddress: '0x1234567890abcdef1234567890abcdef12345678', amount: 100, }); @@ -5372,15 +5336,12 @@ describe('RampsController', () => { await Promise.resolve(); } - // Selection should be updated with fresh data expect(controller.state.quotes.selected?.provider).toBe( '/providers/moonpay', ); expect(controller.state.quotes.selected?.quote.amountOut).toBe( '0.052', ); - - controller.stopQuotePolling(); }, ); }); @@ -5476,7 +5437,7 @@ describe('RampsController', () => { async () => mockQuotesResponse, ); - controller.startQuotePolling({ + controller.fetchQuotesForSelection({ walletAddress: '0x1234567890abcdef1234567890abcdef12345678', amount: 100, }); @@ -5486,515 +5447,100 @@ describe('RampsController', () => { } expect(controller.state.quotes.selected).toBeNull(); - - controller.stopQuotePolling(); }, ); }); }); - describe('stopQuotePolling', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); + describe('setSelectedQuote', () => { + it('sets the selected quote', async () => { + await withController(({ controller }) => { + const quote: Quote = { + provider: '/providers/moonpay', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', + }, + }; - afterEach(() => { - jest.useRealTimers(); - }); + controller.setSelectedQuote(quote); - it('stops polling and clears interval', async () => { - const mockQuotesResponse: QuotesResponse = { - success: [ - { - provider: '/providers/moonpay', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', - }, - }, - ], - sorted: [], - error: [], - customActions: [], - }; + expect(controller.state.quotes.selected).toStrictEqual(quote); + }); + }); + it('clears the selected quote when passed null', async () => { await withController( { options: { state: { - userRegion: createMockUserRegion('us'), - tokens: createResourceState( - { topTokens: [], allTokens: [] }, - { - assetId: 'eip155:1/slip44:60', - chainId: 'eip155:1', - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - iconUrl: 'https://example.com/eth.png', - tokenSupported: true, - }, - ), - providers: createResourceState([], { - id: '/providers/moonpay', - name: 'MoonPay', - environmentType: 'PRODUCTION', - description: 'MoonPay provider', - hqAddress: '123 Test St', - links: [], - logos: { - light: '/assets/providers/moonpay_light.png', - dark: '/assets/providers/moonpay_dark.png', - height: 24, - width: 77, + quotes: createResourceState(null, { + provider: '/providers/moonpay', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', }, }), - paymentMethods: createResourceState( - [ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ), }, }, }, - async ({ controller, rootMessenger }) => { - let callCount = 0; - rootMessenger.registerActionHandler( - 'RampsService:getQuotes', - async () => { - callCount += 1; - return mockQuotesResponse; - }, - ); - - controller.startQuotePolling({ - walletAddress: '0x1234567890abcdef1234567890abcdef12345678', - amount: 100, - }); - - await Promise.resolve(); - await Promise.resolve(); - - expect(callCount).toBe(1); - - controller.stopQuotePolling(); - - // Advance 15 seconds - should not trigger another call - jest.advanceTimersByTime(15000); - await Promise.resolve(); - await Promise.resolve(); + ({ controller }) => { + controller.setSelectedQuote(null); - expect(callCount).toBe(1); + expect(controller.state.quotes.selected).toBeNull(); }, ); }); - it('does not clear quotes data or selection', async () => { - const mockQuotesResponse: QuotesResponse = { - success: [ - { - provider: '/providers/moonpay', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', - }, - }, - ], - sorted: [], - error: [], - customActions: [], - }; + it('fetches widget URL when selecting a quote with buyURL', async () => { + await withController(async ({ controller, rootMessenger }) => { + const buyWidgetResponse = { + url: 'https://global.transak.com/?apiKey=test', + browser: 'APP_BROWSER' as const, + orderId: null, + }; - await withController( - { - options: { - state: { - userRegion: createMockUserRegion('us'), - tokens: createResourceState( - { topTokens: [], allTokens: [] }, - { - assetId: 'eip155:1/slip44:60', - chainId: 'eip155:1', - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - iconUrl: 'https://example.com/eth.png', - tokenSupported: true, - }, - ), - providers: createResourceState([], { - id: '/providers/moonpay', - name: 'MoonPay', - environmentType: 'PRODUCTION', - description: 'MoonPay provider', - hqAddress: '123 Test St', - links: [], - logos: { - light: '/assets/providers/moonpay_light.png', - dark: '/assets/providers/moonpay_dark.png', - height: 24, - width: 77, - }, - }), - paymentMethods: createResourceState( - [ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ), - }, - }, - }, - async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'RampsService:getQuotes', - async () => mockQuotesResponse, - ); + rootMessenger.registerActionHandler( + 'RampsService:getBuyWidgetUrl', + async () => buyWidgetResponse, + ); - controller.startQuotePolling({ - walletAddress: '0x1234567890abcdef1234567890abcdef12345678', - amount: 100, - }); + const quote: Quote = { + provider: '/providers/transak-staging', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', + buyURL: + 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', + }, + }; - await Promise.resolve(); - await Promise.resolve(); + controller.setSelectedQuote(quote); - const quotesData = controller.state.quotes.data; - const selectedQuote = controller.state.quotes.selected; + expect(controller.state.widgetUrl.isLoading).toBe(true); + expect(controller.state.widgetUrl.data).toBeNull(); - controller.stopQuotePolling(); + await flushPromises(); - expect(controller.state.quotes.data).toStrictEqual(quotesData); - expect(controller.state.quotes.selected).toStrictEqual(selectedQuote); - }, - ); + expect(controller.state.widgetUrl.isLoading).toBe(false); + expect(controller.state.widgetUrl.data).toStrictEqual( + buyWidgetResponse, + ); + expect(controller.state.widgetUrl.error).toBeNull(); + }); }); - it('stops polling when setSelectedProvider(null) is called', async () => { - const mockQuotesResponse: QuotesResponse = { - success: [ - { - provider: '/providers/moonpay', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', - }, - }, - ], - sorted: [], - error: [], - customActions: [], - }; - - await withController( - { - options: { - state: { - userRegion: createMockUserRegion('us'), - tokens: createResourceState( - { topTokens: [], allTokens: [] }, - { - assetId: 'eip155:1/slip44:60', - chainId: 'eip155:1', - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - iconUrl: 'https://example.com/eth.png', - tokenSupported: true, - }, - ), - providers: createResourceState([], { - id: '/providers/moonpay', - name: 'MoonPay', - environmentType: 'PRODUCTION', - description: 'MoonPay provider', - hqAddress: '123 Test St', - links: [], - logos: { - light: '/assets/providers/moonpay_light.png', - dark: '/assets/providers/moonpay_dark.png', - height: 24, - width: 77, - }, - }), - paymentMethods: createResourceState( - [ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ), - }, - }, - }, - async ({ controller, rootMessenger }) => { - let callCount = 0; - rootMessenger.registerActionHandler( - 'RampsService:getQuotes', - async () => { - callCount += 1; - return mockQuotesResponse; - }, - ); - - controller.startQuotePolling({ - walletAddress: '0x1234567890abcdef1234567890abcdef12345678', - amount: 100, - }); - - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - expect(callCount).toBe(1); - - // Clear provider - should stop polling - controller.setSelectedProvider(null); - - // Advance 15 seconds - should not trigger another call - jest.advanceTimersByTime(15000); - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - expect(callCount).toBe(1); - }, - ); - }); - - it('stops polling when setSelectedToken(undefined) is called', async () => { - const mockQuotesResponse: QuotesResponse = { - success: [ - { - provider: '/providers/moonpay', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', - }, - }, - ], - sorted: [], - error: [], - customActions: [], - }; - - await withController( - { - options: { - state: { - userRegion: createMockUserRegion('us'), - tokens: createResourceState( - { topTokens: [], allTokens: [] }, - { - assetId: 'eip155:1/slip44:60', - chainId: 'eip155:1', - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - iconUrl: 'https://example.com/eth.png', - tokenSupported: true, - }, - ), - providers: createResourceState([], { - id: '/providers/moonpay', - name: 'MoonPay', - environmentType: 'PRODUCTION', - description: 'MoonPay provider', - hqAddress: '123 Test St', - links: [], - logos: { - light: '/assets/providers/moonpay_light.png', - dark: '/assets/providers/moonpay_dark.png', - height: 24, - width: 77, - }, - }), - paymentMethods: createResourceState( - [ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ), - }, - }, - }, - async ({ controller, rootMessenger }) => { - let callCount = 0; - rootMessenger.registerActionHandler( - 'RampsService:getQuotes', - async () => { - callCount += 1; - return mockQuotesResponse; - }, - ); - - controller.startQuotePolling({ - walletAddress: '0x1234567890abcdef1234567890abcdef12345678', - amount: 100, - }); - - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - expect(callCount).toBe(1); - - // Clear token - should stop polling - controller.setSelectedToken(undefined); - - // Advance 15 seconds - should not trigger another call - jest.advanceTimersByTime(15000); - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - expect(callCount).toBe(1); - }, - ); - }); - }); - - describe('setSelectedQuote', () => { - it('sets the selected quote', async () => { - await withController(({ controller }) => { - const quote: Quote = { - provider: '/providers/moonpay', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', - }, - }; - - controller.setSelectedQuote(quote); - - expect(controller.state.quotes.selected).toStrictEqual(quote); - }); - }); - - it('clears the selected quote when passed null', async () => { - await withController( - { - options: { - state: { - quotes: createResourceState(null, { - provider: '/providers/moonpay', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', - }, - }), - }, - }, - }, - ({ controller }) => { - controller.setSelectedQuote(null); - - expect(controller.state.quotes.selected).toBeNull(); - }, - ); - }); - - it('fetches widget URL when selecting a quote with buyURL', async () => { - await withController(async ({ controller, rootMessenger }) => { - const buyWidgetResponse = { - url: 'https://global.transak.com/?apiKey=test', - browser: 'APP_BROWSER' as const, - orderId: null, - }; - - rootMessenger.registerActionHandler( - 'RampsService:getBuyWidgetUrl', - async () => buyWidgetResponse, - ); - - const quote: Quote = { - provider: '/providers/transak-staging', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', - buyURL: - 'https://on-ramp.uat-api.cx.metamask.io/providers/transak-staging/buy-widget', - }, - }; - - controller.setSelectedQuote(quote); - - expect(controller.state.widgetUrl.isLoading).toBe(true); - expect(controller.state.widgetUrl.data).toBeNull(); - - await flushPromises(); - - expect(controller.state.widgetUrl.isLoading).toBe(false); - expect(controller.state.widgetUrl.data).toStrictEqual( - buyWidgetResponse, - ); - expect(controller.state.widgetUrl.error).toBeNull(); - }); - }); - - it('resets widget URL when selecting a quote without buyURL', async () => { - await withController(({ controller }) => { - const quote: Quote = { - provider: '/providers/moonpay', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', + it('resets widget URL when selecting a quote without buyURL', async () => { + await withController(({ controller }) => { + const quote: Quote = { + provider: '/providers/moonpay', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', }, }; @@ -6172,241 +5718,25 @@ describe('RampsController', () => { }); }); - describe('polling restart on dependency changes', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('restarts polling when payment method changes', async () => { - const mockQuotesResponse: QuotesResponse = { - success: [ - { - provider: '/providers/moonpay', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', - }, - }, - ], - sorted: [], - error: [], - customActions: [], - }; - - await withController( - { - options: { - state: { - userRegion: createMockUserRegion('us'), - tokens: createResourceState( - { topTokens: [], allTokens: [] }, - { - assetId: 'eip155:1/slip44:60', - chainId: 'eip155:1', - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - iconUrl: 'https://example.com/eth.png', - tokenSupported: true, - }, - ), - providers: createResourceState([], { - id: '/providers/moonpay', - name: 'MoonPay', - environmentType: 'PRODUCTION', - description: 'MoonPay provider', - hqAddress: '123 Test St', - links: [], - logos: { - light: '/assets/providers/moonpay_light.png', - dark: '/assets/providers/moonpay_dark.png', - height: 24, - width: 77, - }, - }), - paymentMethods: createResourceState( - [ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - { - id: '/payments/bank-transfer', - paymentType: 'bank-transfer', - name: 'Bank Transfer', - score: 85, - icon: 'bank', - }, - ], - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ), - }, - }, - }, - async ({ controller, rootMessenger }) => { - const callTimes: number[] = []; - rootMessenger.registerActionHandler( - 'RampsService:getQuotes', - async () => { - callTimes.push(Date.now()); - return mockQuotesResponse; - }, - ); - - controller.startQuotePolling({ - walletAddress: '0x1234567890abcdef1234567890abcdef12345678', - amount: 100, - }); - - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - const initialCallCount = callTimes.length; - expect(initialCallCount).toBeGreaterThan(0); - - // Change payment method - this should restart polling - controller.setSelectedPaymentMethod('/payments/bank-transfer'); - - // Advance time to trigger the next poll - jest.advanceTimersByTime(16000); - for (let i = 0; i < 20; i++) { - await Promise.resolve(); - } - - // Polling should still be active (call count increased) - expect(callTimes.length).toBeGreaterThan(initialCallCount); - - controller.stopQuotePolling(); - }, - ); - }); - }); - describe('destroy', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); + it('clears stateChange subscriptions so listeners stop firing', async () => { + await withController(({ controller, messenger }) => { + const listener = jest.fn(); + messenger.subscribe('RampsController:stateChange', listener); - afterEach(() => { - jest.useRealTimers(); - }); + controller.destroy(); - it('stops quote polling when called', async () => { - const mockQuotesResponse: QuotesResponse = { - success: [ - { - provider: '/providers/moonpay', - quote: { - amountIn: 100, - amountOut: '0.05', - paymentMethod: '/payments/debit-credit-card', - }, - }, - ], - sorted: [], - error: [], - customActions: [], - }; - - await withController( - { - options: { - state: { - userRegion: createMockUserRegion('us'), - tokens: createResourceState( - { topTokens: [], allTokens: [] }, - { - assetId: 'eip155:1/slip44:60', - chainId: 'eip155:1', - name: 'Ethereum', - symbol: 'ETH', - decimals: 18, - iconUrl: 'https://example.com/eth.png', - tokenSupported: true, - }, - ), - providers: createResourceState([], { - id: '/providers/moonpay', - name: 'MoonPay', - environmentType: 'PRODUCTION', - description: 'MoonPay provider', - hqAddress: '123 Test St', - links: [], - logos: { - light: '/assets/providers/moonpay_light.png', - dark: '/assets/providers/moonpay_dark.png', - height: 24, - width: 77, - }, - }), - paymentMethods: createResourceState( - [ - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ], - { - id: '/payments/debit-credit-card', - paymentType: 'debit-credit-card', - name: 'Debit or Credit', - score: 90, - icon: 'card', - }, - ), - }, + controller.setSelectedQuote({ + provider: '/providers/moonpay', + quote: { + amountIn: 100, + amountOut: '0.05', + paymentMethod: '/payments/debit-credit-card', }, - }, - async ({ controller, rootMessenger }) => { - let callCount = 0; - rootMessenger.registerActionHandler( - 'RampsService:getQuotes', - async () => { - callCount += 1; - return mockQuotesResponse; - }, - ); - - controller.startQuotePolling({ - walletAddress: '0x1234567890abcdef1234567890abcdef12345678', - amount: 100, - }); - - for (let i = 0; i < 10; i++) { - await Promise.resolve(); - } - - expect(callCount).toBe(1); - - // Call destroy - controller.destroy(); - - // Advance time - polling should not fire - jest.advanceTimersByTime(30000); - await flushPromises(); + }); - // Call count should still be 1 - expect(callCount).toBe(1); - }, - ); + expect(listener).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 910a0df2970..e61603b4a46 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -648,22 +648,6 @@ export class RampsController extends BaseController< */ readonly #pendingResourceCount: Map = new Map(); - /** - * Interval ID for automatic quote polling. - * Set when startQuotePolling() is called, cleared when stopQuotePolling() is called. - */ - #quotePollingInterval: ReturnType | null = null; - - /** - * Options used for quote polling (walletAddress, amount, redirectUrl). - * Stored so polling can be restarted when dependencies change. - */ - #quotePollingOptions: { - walletAddress: string; - amount: number; - redirectUrl?: string; - } | null = null; - /** * Clears the pending resource count map. Used only in tests to exercise the * defensive path when get() returns undefined in the finally block. @@ -905,7 +889,6 @@ export class RampsController extends BaseController< } #cleanupState(): void { - this.stopQuotePolling(); this.#abortDependentRequests(); this.#clearPendingResourceCountForDependentResources(); this.update((state) => @@ -923,24 +906,6 @@ export class RampsController extends BaseController< promise.catch((_error: unknown) => undefined); } - /** - * Restarts quote polling if it's currently active. - * Used when dependencies change (token, provider, payment method). - * Will only restart if all dependencies are still met (startQuotePolling validates this). - */ - #restartPollingIfActive(): void { - if (this.#quotePollingInterval !== null && this.#quotePollingOptions) { - const options = this.#quotePollingOptions; - this.stopQuotePolling(); - try { - this.startQuotePolling(options); - } catch { - // Dependencies not met yet, polling will need to be manually restarted - // when dependencies are available - } - } - } - #requireRegion(): string { const regionCode = this.state.userRegion?.regionCode; if (!regionCode) { @@ -1100,7 +1065,6 @@ export class RampsController extends BaseController< if (regionChanged) { this.#abortDependentRequests(); this.#clearPendingResourceCountForDependentResources(); - this.stopQuotePolling(); } this.update((state) => { if (regionChanged) { @@ -1143,7 +1107,6 @@ export class RampsController extends BaseController< */ setSelectedProvider(providerId: string | null): void { if (providerId === null) { - this.stopQuotePolling(); this.update((state) => { state.providers.selected = null; resetResource(state, 'paymentMethods'); @@ -1174,11 +1137,7 @@ export class RampsController extends BaseController< }); this.#fireAndForget( - this.getPaymentMethods(regionCode, { provider: provider.id }).then(() => { - // Restart quote polling after payment methods are fetched - this.#restartPollingIfActive(); - return undefined; - }), + this.getPaymentMethods(regionCode, { provider: provider.id }), ); } @@ -1306,7 +1265,6 @@ export class RampsController extends BaseController< */ setSelectedToken(assetId?: string): void { if (!assetId) { - this.stopQuotePolling(); this.update((state) => { state.tokens.selected = null; resetResource(state, 'paymentMethods'); @@ -1340,12 +1298,7 @@ export class RampsController extends BaseController< }); this.#fireAndForget( - this.getPaymentMethods(regionCode, { assetId: token.assetId }).then( - () => { - this.#restartPollingIfActive(); - return undefined; - }, - ), + this.getPaymentMethods(regionCode, { assetId: token.assetId }), ); } @@ -1537,9 +1490,6 @@ export class RampsController extends BaseController< this.update((state) => { state.paymentMethods.selected = paymentMethod; }); - - // Restart quote polling if active - this.#restartPollingIfActive(); } /** @@ -1667,8 +1617,7 @@ export class RampsController extends BaseController< } /** - * Starts automatic quote polling with a 15-second refresh interval. - * Fetches quotes immediately and then every 15 seconds. + * Fetches quotes for the currently selected token, provider, and payment method. * If the response contains exactly one quote, it is auto-selected. * If multiple quotes are returned, the existing selection is preserved if still valid. * @@ -1681,7 +1630,7 @@ export class RampsController extends BaseController< * @param options.redirectUrl - Optional redirect URL after order completion. * @throws If required dependencies (region, token, provider) are not set. */ - startQuotePolling(options: { + fetchQuotesForSelection(options: { walletAddress: string; amount: number; redirectUrl?: string; @@ -1693,13 +1642,13 @@ export class RampsController extends BaseController< if (!token) { throw new Error( - 'Token is required. Cannot start quote polling without a selected token.', + 'Token is required. Cannot fetch quotes without a selected token.', ); } if (!provider) { throw new Error( - 'Provider is required. Cannot start quote polling without a selected provider.', + 'Provider is required. Cannot fetch quotes without a selected provider.', ); } @@ -1707,70 +1656,41 @@ export class RampsController extends BaseController< return; } - // Stop any existing polling first - this.stopQuotePolling(); - - // Store options for restarts (must be after stop to avoid being cleared) - this.#quotePollingOptions = options; - - // Define the fetch function - const fetchQuotes = (): void => { - this.#fireAndForget( - this.getQuotes({ - assetId: token.assetId, - amount: options.amount, - walletAddress: options.walletAddress, - redirectUrl: options.redirectUrl, - paymentMethods: [paymentMethod.id], - providers: [provider.id], - forceRefresh: true, - }).then((response) => { - let newSelectedQuote: Quote | null = null; - - // Auto-select logic: only when exactly one quote is returned - this.update((state) => { - if (response.success.length === 1) { - newSelectedQuote = response.success[0]; + this.#fireAndForget( + this.getQuotes({ + assetId: token.assetId, + amount: options.amount, + walletAddress: options.walletAddress, + redirectUrl: options.redirectUrl, + paymentMethods: [paymentMethod.id], + providers: [provider.id], + forceRefresh: true, + }).then((response) => { + let newSelectedQuote: Quote | null = null; + + this.update((state) => { + if (response.success.length === 1) { + newSelectedQuote = response.success[0]; + state.quotes.selected = newSelectedQuote; + } else { + const currentSelection = state.quotes.selected; + if (currentSelection) { + const freshQuote = response.success.find( + (quote) => + quote.provider === currentSelection.provider && + quote.quote.paymentMethod === + currentSelection.quote.paymentMethod, + ); + newSelectedQuote = freshQuote ?? null; state.quotes.selected = newSelectedQuote; - } else { - // Keep existing selection if still valid, but update with fresh data - const currentSelection = state.quotes.selected; - if (currentSelection) { - const freshQuote = response.success.find( - (quote) => - quote.provider === currentSelection.provider && - quote.quote.paymentMethod === - currentSelection.quote.paymentMethod, - ); - newSelectedQuote = freshQuote ?? null; - state.quotes.selected = newSelectedQuote; - } } - }); - - this.#syncWidgetUrl(newSelectedQuote); - return undefined; - }), - ); - }; - - // Fetch immediately - fetchQuotes(); - - // Set up 15-second polling - this.#quotePollingInterval = setInterval(fetchQuotes, 15000); - } + } + }); - /** - * Stops automatic quote polling. - * Does not clear quotes data or selection, only stops the interval. - */ - stopQuotePolling(): void { - if (this.#quotePollingInterval !== null) { - clearInterval(this.#quotePollingInterval); - this.#quotePollingInterval = null; - } - this.#quotePollingOptions = null; + this.#syncWidgetUrl(newSelectedQuote); + return undefined; + }), + ); } /** @@ -1788,11 +1708,9 @@ export class RampsController extends BaseController< /** * Cleans up controller resources. - * Stops any active quote polling to prevent memory leaks. * Should be called when the controller is no longer needed. */ override destroy(): void { - this.stopQuotePolling(); super.destroy(); }