diff --git a/knip.ts b/knip.ts index 5c4794cee..c79a7c0d3 100755 --- a/knip.ts +++ b/knip.ts @@ -41,6 +41,7 @@ const config: KnipConfig = { ], ignore: [ 'src/redux/reducersForSchemaGeneration.ts', // used for root state schema generation + 'src/dollarsSpend/index.ts', // barrel; consumed by Phase 3.2 UI integration (TokenBottomSheet/Swap/Gold/Send) which ships separately 'src/analytics/docs.ts', // documents analytics events, no references 'src/account/__mocks__/Persona.tsx', // unit test mocks 'src/firebase/remoteConfigValuesDefaults.e2e.ts', // e2e test setup diff --git a/src/dollarsSpend/index.ts b/src/dollarsSpend/index.ts new file mode 100644 index 000000000..6116ed3c3 --- /dev/null +++ b/src/dollarsSpend/index.ts @@ -0,0 +1,23 @@ +export { DOLARES_VIRTUAL_TOKEN_ID, SPEND_ORDER } from 'src/dollarsSpend/types' +export type { + DollarSymbol, + SpendStep, + MultiSwapPlan, + DollarTokenBalanceSnapshot, +} from 'src/dollarsSpend/types' +export { planSpend } from 'src/dollarsSpend/planSpend' +export { useMultiSwapQuote } from 'src/dollarsSpend/useMultiSwapQuote' +export { executeMultiSwap } from 'src/dollarsSpend/saga' +export { + multiSwapStarted, + multiSwapStepSucceeded, + multiSwapStepFailed, + multiSwapCompleted, + multiSwapCleared, +} from 'src/dollarsSpend/slice' +export { + inFlightSelector, + hasInFlightSelector, + inFlightProgressSelector, +} from 'src/dollarsSpend/selectors' +export { useDollarBalanceSnapshots } from 'src/dollarsSpend/useDollarBalanceSnapshots' diff --git a/src/dollarsSpend/planSpend.test.ts b/src/dollarsSpend/planSpend.test.ts new file mode 100644 index 000000000..d5991ee0b --- /dev/null +++ b/src/dollarsSpend/planSpend.test.ts @@ -0,0 +1,133 @@ +import BigNumber from 'bignumber.js' +import { planSpend } from 'src/dollarsSpend/planSpend' +import { DollarTokenBalanceSnapshot } from 'src/dollarsSpend/types' + +// Helpers to build snapshots quickly. Order in helper does NOT influence +// the planner; planner enforces SPEND_ORDER internally. +function snap( + symbol: 'USAT' | 'USDm' | 'USDC' | 'USDT', + tokenId: string, + balance: number, + priceUsd = 1, + minAmountUsd = 0 +): DollarTokenBalanceSnapshot { + return { + symbol, + tokenId, + balance: new BigNumber(balance), + priceUsd: new BigNumber(priceUsd), + minAmountUsd: new BigNumber(minAmountUsd), + } +} + +const USAT = (b: number, p = 1, m = 0) => snap('USAT', 'celo-mainnet:usat', b, p, m) +const USDM = (b: number, p = 1, m = 0) => snap('USDm', 'celo-mainnet:usdm', b, p, m) +const USDC = (b: number, p = 1, m = 0) => snap('USDC', 'celo-mainnet:usdc', b, p, m) +const USDT = (b: number, p = 1, m = 0) => snap('USDT', 'celo-mainnet:usdt', b, p, m) + +describe('planSpend', () => { + it('returns no steps and zero shortfall when requested is 0', () => { + const plan = planSpend({ + requestedUsd: new BigNumber(0), + balances: [USAT(100), USDT(100)], + }) + expect(plan.steps).toEqual([]) + expect(plan.shortfall.toString()).toBe('0') + }) + + it('uses USAT alone when it covers the request', () => { + const plan = planSpend({ + requestedUsd: new BigNumber(25), + balances: [USAT(100), USDM(100), USDC(100), USDT(100)], + }) + expect(plan.steps).toHaveLength(1) + expect(plan.steps[0].symbol).toBe('USAT') + expect(plan.steps[0].amountUsd.toString()).toBe('25') + expect(plan.shortfall.toString()).toBe('0') + }) + + it('walks the spend order when no single token covers', () => { + const plan = planSpend({ + requestedUsd: new BigNumber(150), + balances: [USAT(30), USDM(50), USDC(100), USDT(200)], + }) + expect(plan.steps.map((s) => s.symbol)).toEqual(['USAT', 'USDm', 'USDC']) + expect(plan.steps[0].amountUsd.toString()).toBe('30') + expect(plan.steps[1].amountUsd.toString()).toBe('50') + expect(plan.steps[2].amountUsd.toString()).toBe('70') + expect(plan.shortfall.toString()).toBe('0') + }) + + it('respects priority even when later token covers alone', () => { + // USDT has $200 alone; planner still consumes USAT first then USDm etc. + const plan = planSpend({ + requestedUsd: new BigNumber(120), + balances: [USAT(30), USDM(50), USDC(0), USDT(200)], + }) + expect(plan.steps.map((s) => s.symbol)).toEqual(['USAT', 'USDm', 'USDT']) + expect(plan.steps[2].amountUsd.toString()).toBe('40') + expect(plan.shortfall.toString()).toBe('0') + }) + + it('skips tokens with balanceUsd below minAmountUsd (dust filter)', () => { + const plan = planSpend({ + requestedUsd: new BigNumber(20), + balances: [USAT(0.5, 1, 1), USDM(50, 1, 1), USDT(0, 1, 1)], + }) + expect(plan.steps.map((s) => s.symbol)).toEqual(['USDm']) + expect(plan.steps[0].amountUsd.toString()).toBe('20') + expect(plan.shortfall.toString()).toBe('0') + }) + + it('reports shortfall when total balance is insufficient', () => { + const plan = planSpend({ + requestedUsd: new BigNumber(500), + balances: [USAT(30), USDM(50), USDC(70), USDT(50)], + }) + expect(plan.steps.map((s) => s.symbol)).toEqual(['USAT', 'USDm', 'USDC', 'USDT']) + expect(plan.shortfall.toString()).toBe('300') + }) + + it('accounts for priceUsd != 1 when computing USD per token', () => { + // 100 USDm at 0.998 USD = 99.8 USD + const plan = planSpend({ + requestedUsd: new BigNumber(50), + balances: [USAT(0), USDM(100, 0.998), USDT(200)], + }) + expect(plan.steps.map((s) => s.symbol)).toEqual(['USDm']) + expect(plan.steps[0].amountUsd.toString()).toBe('50') + // 50 USD / 0.998 = 50.10020... USDm whole units + expect(plan.steps[0].amountTokenWhole.toFixed(2)).toBe('50.10') + }) + + it('handles a token with priceUsd = 0 by skipping it', () => { + const plan = planSpend({ + requestedUsd: new BigNumber(20), + balances: [USAT(100, 0), USDM(50)], + }) + expect(plan.steps.map((s) => s.symbol)).toEqual(['USDm']) + }) + + it('ignores balances for tokens not in SPEND_ORDER', () => { + // CELO native or random token in the balances array should be ignored. + const plan = planSpend({ + requestedUsd: new BigNumber(20), + balances: [ + USAT(0), + USDM(0), + USDC(0), + USDT(0), + // Random token that planSpend should ignore + { + symbol: 'CELO' as any, + tokenId: 'celo-mainnet:native', + balance: new BigNumber(100), + priceUsd: new BigNumber(0.3), + minAmountUsd: new BigNumber(0), + } as any, + ], + }) + expect(plan.steps).toEqual([]) + expect(plan.shortfall.toString()).toBe('20') + }) +}) diff --git a/src/dollarsSpend/planSpend.ts b/src/dollarsSpend/planSpend.ts new file mode 100644 index 000000000..a8cc63ddb --- /dev/null +++ b/src/dollarsSpend/planSpend.ts @@ -0,0 +1,57 @@ +import BigNumber from 'bignumber.js' +import { DollarTokenBalanceSnapshot, MultiSwapPlan, SpendStep } from 'src/dollarsSpend/types' + +// Hard-coded priority. Top-of-list is consumed first. +// See spec rationale: spend least-liquid / most-regulated first. +const SPEND_ORDER_SYMBOLS: SpendStep['symbol'][] = ['USAT', 'USDm', 'USDC', 'USDT'] + +export function planSpend({ + requestedUsd, + balances, +}: { + requestedUsd: BigNumber + balances: DollarTokenBalanceSnapshot[] +}): MultiSwapPlan { + const steps: SpendStep[] = [] + let remaining = requestedUsd + + if (remaining.lte(0)) { + return { steps, shortfall: new BigNumber(0) } + } + + // Index balances by symbol so the planner is order-independent on input. + const bySymbol: Partial> = {} + for (const b of balances) { + if (SPEND_ORDER_SYMBOLS.includes(b.symbol)) { + bySymbol[b.symbol] = b + } + } + + for (const symbol of SPEND_ORDER_SYMBOLS) { + if (remaining.lte(0)) break + const snap = bySymbol[symbol] + if (!snap) continue + if (snap.priceUsd.lte(0)) continue + + const balanceUsd = snap.balance.multipliedBy(snap.priceUsd) + if (balanceUsd.lt(snap.minAmountUsd)) continue // dust + if (balanceUsd.lte(0)) continue + + const takeUsd = BigNumber.min(balanceUsd, remaining) + const takeTokenWhole = takeUsd.dividedBy(snap.priceUsd) + + steps.push({ + tokenId: snap.tokenId, + symbol, + amountUsd: takeUsd, + amountTokenWhole: takeTokenWhole, + }) + + remaining = remaining.minus(takeUsd) + } + + return { + steps, + shortfall: BigNumber.max(remaining, new BigNumber(0)), + } +} diff --git a/src/dollarsSpend/saga.test.ts b/src/dollarsSpend/saga.test.ts new file mode 100644 index 000000000..ebdfdd1ee --- /dev/null +++ b/src/dollarsSpend/saga.test.ts @@ -0,0 +1,133 @@ +import BigNumber from 'bignumber.js' +import { expectSaga } from 'redux-saga-test-plan' +import * as matchers from 'redux-saga-test-plan/matchers' +import { EffectProviders, StaticProvider } from 'redux-saga-test-plan/providers' +import { executeMultiSwap, executeMultiSwapSaga } from 'src/dollarsSpend/saga' +import { + multiSwapCompleted, + multiSwapStarted, + multiSwapStepFailed, + multiSwapStepSucceeded, +} from 'src/dollarsSpend/slice' +import { SpendStep } from 'src/dollarsSpend/types' +import { swapError, swapSuccess } from 'src/swap/slice' +import { fetchSwapQuote } from 'src/swap/useSwapQuote' +import { walletAddressSelector } from 'src/web3/selectors' + +jest.mock('src/swap/useSwapQuote', () => ({ + ...jest.requireActual('src/swap/useSwapQuote'), + fetchSwapQuote: jest.fn(), +})) + +const stepUsat: SpendStep = { + tokenId: 'celo-mainnet:usat', + symbol: 'USAT', + amountUsd: new BigNumber(30), + amountTokenWhole: new BigNumber(30), +} +const stepUsdm: SpendStep = { + tokenId: 'celo-mainnet:usdm', + symbol: 'USDm', + amountUsd: new BigNumber(50), + amountTokenWhole: new BigNumber(50), +} + +const mockQuoteResult = (fromTokenId: string) => ({ + fromTokenId, + toTokenId: 'celo-mainnet:copm', + swapAmount: { FROM: new BigNumber(30), TO: new BigNumber(122_400) }, + price: '4080', + provider: 'squid', + estimatedPriceImpact: null, +}) + +const MOCK_WALLET = '0x1234567890abcdef1234567890abcdef12345678' + +// Base providers shared across tests: mock the wallet select so +// the saga doesn't crash trying to access state.web3 without a store. +function baseProviders(): StaticProvider[] { + return [[matchers.select.selector(walletAddressSelector), MOCK_WALLET]] +} + +describe('executeMultiSwapSaga', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(fetchSwapQuote).mockResolvedValue(mockQuoteResult('celo-mainnet:usat') as any) + }) + + it('runs the happy path: 2 steps both succeed', async () => { + let raceCallIndex = 0 + const raceProvider: EffectProviders = { + race(_effect, _next) { + raceCallIndex += 1 + return { + success: { type: swapSuccess.type, payload: { swapId: `mocked-${raceCallIndex}` } }, + } + }, + } + + const providers: (EffectProviders | StaticProvider)[] = [ + ...baseProviders(), + [matchers.call.fn(fetchSwapQuote), mockQuoteResult('celo-mainnet:usat')], + raceProvider, + ] + + await expectSaga( + executeMultiSwapSaga, + executeMultiSwap({ steps: [stepUsat, stepUsdm], toTokenId: 'celo-mainnet:copm' }) + ) + .provide(providers) + .put(multiSwapStarted({ steps: [stepUsat, stepUsdm] })) + .put(multiSwapStepSucceeded({ index: 0 })) + .put(multiSwapStepSucceeded({ index: 1 })) + .put(multiSwapCompleted()) + .not.put.actionType(multiSwapStepFailed.type) + .silentRun() + }) + + it('halts and emits stepFailed when a step swap fails', async () => { + let raceCallIndex = 0 + const raceProvider: EffectProviders = { + race(_effect, _next) { + raceCallIndex += 1 + if (raceCallIndex === 1) { + return { success: { type: swapSuccess.type, payload: { swapId: 'mocked-1' } } } + } + return { error: { type: swapError.type, payload: 'mocked-2' } } + }, + } + + const providers: (EffectProviders | StaticProvider)[] = [ + ...baseProviders(), + [matchers.call.fn(fetchSwapQuote), mockQuoteResult('celo-mainnet:usat')], + raceProvider, + ] + + await expectSaga( + executeMultiSwapSaga, + executeMultiSwap({ steps: [stepUsat, stepUsdm], toTokenId: 'celo-mainnet:copm' }) + ) + .provide(providers) + .put(multiSwapStepSucceeded({ index: 0 })) + .put.actionType(multiSwapStepFailed.type) + .not.put.actionType(multiSwapCompleted.type) + .silentRun() + }) + + it('emits stepFailed when a quote refetch throws', async () => { + const quoteError = new Error('Squid 500') + const providers: (EffectProviders | StaticProvider)[] = [ + ...baseProviders(), + [matchers.call.fn(fetchSwapQuote), Promise.reject(quoteError) as any], + ] + + await expectSaga( + executeMultiSwapSaga, + executeMultiSwap({ steps: [stepUsat], toTokenId: 'celo-mainnet:copm' }) + ) + .provide(providers) + .put.actionType(multiSwapStepFailed.type) + .not.put.actionType(multiSwapCompleted.type) + .silentRun() + }) +}) diff --git a/src/dollarsSpend/saga.ts b/src/dollarsSpend/saga.ts new file mode 100644 index 000000000..ad5c2f896 --- /dev/null +++ b/src/dollarsSpend/saga.ts @@ -0,0 +1,118 @@ +import { createAction, PayloadAction } from '@reduxjs/toolkit' +import { call, put, race, select, take, takeEvery } from 'typed-redux-saga' +import { + multiSwapCompleted, + multiSwapStarted, + multiSwapStepFailed, + multiSwapStepSucceeded, +} from 'src/dollarsSpend/slice' +import { SpendStep } from 'src/dollarsSpend/types' +import { swapStart, swapSuccess, swapError } from 'src/swap/slice' +import { Field, SwapInfo } from 'src/swap/types' +import { FetchSwapQuoteArgs, fetchSwapQuote } from 'src/swap/useSwapQuote' +import Logger from 'src/utils/Logger' +import { walletAddressSelector } from 'src/web3/selectors' + +const TAG = 'dollarsSpend/saga' + +export interface ExecuteMultiSwapPayload { + steps: SpendStep[] + toTokenId: string +} + +export const executeMultiSwap = createAction( + 'dollarsSpend/executeMultiSwap' +) + +function newSwapId(index: number) { + return `multi-${Date.now()}-${index}-${Math.floor(Math.random() * 1e6)}` +} + +export function* executeMultiSwapSaga(action: PayloadAction) { + const { steps, toTokenId } = action.payload + + if (steps.length === 0) { + return + } + + yield* put(multiSwapStarted({ steps })) + + const walletAddress = (yield* select(walletAddressSelector)) ?? '' + + for (let index = 0; index < steps.length; index++) { + const step = steps[index] + const swapId = newSwapId(index) + + const fetchArgs: FetchSwapQuoteArgs = { + fromTokenId: step.tokenId, + toTokenId, + amount: step.amountTokenWhole.toString(), + walletAddress, + } + + let freshQuote: Awaited> + try { + freshQuote = yield* call(fetchSwapQuote, fetchArgs) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + Logger.warn(TAG, `Quote refetch failed for step ${index} (${step.symbol}): ${message}`) + yield* put(multiSwapStepFailed({ index, errorMessage: message })) + return + } + + const swapInfo: SwapInfo = { + swapId, + userInput: { + fromTokenId: step.tokenId, + toTokenId, + swapAmount: { + [Field.FROM]: step.amountTokenWhole.toString(), + [Field.TO]: freshQuote.swapAmount.TO.toString(), + }, + updatedField: Field.FROM, + }, + quote: { + // preparedTransactions are empty here - this saga only orchestrates at + // the price-discovery level. The actual tx preparation is handled by + // the swap screen before the user confirms; this saga fires after that. + preparedTransactions: [], + receivedAt: Date.now(), + price: freshQuote.price, + appFeePercentageIncludedInPrice: undefined, + provider: freshQuote.provider, + estimatedPriceImpact: freshQuote.estimatedPriceImpact, + allowanceTarget: '', + swapType: 'same-chain', + }, + areSwapTokensShuffled: false, + } + + yield* put(swapStart(swapInfo)) + + // swapSuccess payload is SwapResult { swapId, ... } + // swapError payload is a raw swapId string + const { success, error } = yield* race({ + success: take((a: any) => a.type === swapSuccess.type && a.payload?.swapId === swapId), + error: take((a: any) => a.type === swapError.type && a.payload === swapId), + }) + + if (success) { + yield* put(multiSwapStepSucceeded({ index })) + } else if (error) { + Logger.warn(TAG, `Swap failed at step ${index} (${step.symbol})`) + yield* put( + multiSwapStepFailed({ + index, + errorMessage: `Swap failed at step ${index} (${step.symbol})`, + }) + ) + return + } + } + + yield* put(multiSwapCompleted()) +} + +export function* dollarsSpendSaga() { + yield* takeEvery(executeMultiSwap.type, executeMultiSwapSaga) +} diff --git a/src/dollarsSpend/selectors.test.ts b/src/dollarsSpend/selectors.test.ts new file mode 100644 index 000000000..47cf83112 --- /dev/null +++ b/src/dollarsSpend/selectors.test.ts @@ -0,0 +1,65 @@ +import BigNumber from 'bignumber.js' +import { + inFlightSelector, + hasInFlightSelector, + inFlightProgressSelector, +} from 'src/dollarsSpend/selectors' +import { SpendStep } from 'src/dollarsSpend/types' + +const step: SpendStep = { + tokenId: 'celo-mainnet:usat', + symbol: 'USAT', + amountUsd: new BigNumber(30), + amountTokenWhole: new BigNumber(30), +} + +describe('dollarsSpend selectors', () => { + it('inFlightSelector returns null when no in-flight session', () => { + expect(inFlightSelector({ dollarsSpend: { inFlight: null } } as any)).toBeNull() + }) + + it('inFlightSelector returns the in-flight session when present', () => { + const session = { + plannedSteps: [step], + completedSteps: 0, + failedAtIndex: null, + lastError: null, + } + expect(inFlightSelector({ dollarsSpend: { inFlight: session } } as any)).toEqual(session) + }) + + it('hasInFlightSelector returns false / true', () => { + expect(hasInFlightSelector({ dollarsSpend: { inFlight: null } } as any)).toBe(false) + expect( + hasInFlightSelector({ + dollarsSpend: { + inFlight: { + plannedSteps: [step], + completedSteps: 0, + failedAtIndex: null, + lastError: null, + }, + }, + } as any) + ).toBe(true) + }) + + it('inFlightProgressSelector returns { completed, total, failedAtIndex }', () => { + expect( + inFlightProgressSelector({ + dollarsSpend: { + inFlight: { + plannedSteps: [step, step], + completedSteps: 1, + failedAtIndex: null, + lastError: null, + }, + }, + } as any) + ).toEqual({ completed: 1, total: 2, failedAtIndex: null }) + }) + + it('inFlightProgressSelector returns null when no in-flight', () => { + expect(inFlightProgressSelector({ dollarsSpend: { inFlight: null } } as any)).toBeNull() + }) +}) diff --git a/src/dollarsSpend/selectors.ts b/src/dollarsSpend/selectors.ts new file mode 100644 index 000000000..d3854db97 --- /dev/null +++ b/src/dollarsSpend/selectors.ts @@ -0,0 +1,15 @@ +import { RootState } from 'src/redux/store' + +export const inFlightSelector = (state: RootState) => state.dollarsSpend.inFlight + +export const hasInFlightSelector = (state: RootState) => state.dollarsSpend.inFlight !== null + +export const inFlightProgressSelector = (state: RootState) => { + const inFlight = state.dollarsSpend.inFlight + if (!inFlight) return null + return { + completed: inFlight.completedSteps, + total: inFlight.plannedSteps.length, + failedAtIndex: inFlight.failedAtIndex, + } +} diff --git a/src/dollarsSpend/slice.test.ts b/src/dollarsSpend/slice.test.ts new file mode 100644 index 000000000..6696e2d23 --- /dev/null +++ b/src/dollarsSpend/slice.test.ts @@ -0,0 +1,80 @@ +import BigNumber from 'bignumber.js' +import reducer, { + multiSwapStarted, + multiSwapStepSucceeded, + multiSwapStepFailed, + multiSwapCompleted, + multiSwapCleared, +} from 'src/dollarsSpend/slice' +import { SpendStep } from 'src/dollarsSpend/types' + +const stepUsat: SpendStep = { + tokenId: 'celo-mainnet:usat', + symbol: 'USAT', + amountUsd: new BigNumber(30), + amountTokenWhole: new BigNumber(30), +} +const stepUsdm: SpendStep = { + tokenId: 'celo-mainnet:usdm', + symbol: 'USDm', + amountUsd: new BigNumber(50), + amountTokenWhole: new BigNumber(50), +} + +describe('dollarsSpend slice', () => { + it('returns initial state', () => { + expect(reducer(undefined, { type: 'init' })).toEqual({ inFlight: null }) + }) + + it('handles multiSwapStarted by setting inFlight with planned steps', () => { + const state = reducer(undefined, multiSwapStarted({ steps: [stepUsat, stepUsdm] })) + expect(state.inFlight).not.toBeNull() + expect(state.inFlight?.plannedSteps).toEqual([stepUsat, stepUsdm]) + expect(state.inFlight?.completedSteps).toBe(0) + expect(state.inFlight?.failedAtIndex).toBeNull() + expect(state.inFlight?.lastError).toBeNull() + }) + + it('increments completedSteps on step success', () => { + const startedState = reducer(undefined, multiSwapStarted({ steps: [stepUsat, stepUsdm] })) + const next = reducer(startedState, multiSwapStepSucceeded({ index: 0 })) + expect(next.inFlight?.completedSteps).toBe(1) + }) + + it('records failedAtIndex and lastError on step failure', () => { + const startedState = reducer(undefined, multiSwapStarted({ steps: [stepUsat, stepUsdm] })) + const failed = reducer( + startedState, + multiSwapStepFailed({ index: 1, errorMessage: 'slippage exceeded' }) + ) + expect(failed.inFlight?.failedAtIndex).toBe(1) + expect(failed.inFlight?.lastError).toBe('slippage exceeded') + }) + + it('clears inFlight on multiSwapCompleted', () => { + const startedState = reducer(undefined, multiSwapStarted({ steps: [stepUsat] })) + const succeeded = reducer(startedState, multiSwapStepSucceeded({ index: 0 })) + const completed = reducer(succeeded, multiSwapCompleted()) + expect(completed.inFlight).toBeNull() + }) + + it('clears inFlight on multiSwapCleared (user dismissed sheet)', () => { + const startedState = reducer(undefined, multiSwapStarted({ steps: [stepUsat] })) + const failed = reducer( + startedState, + multiSwapStepFailed({ index: 0, errorMessage: 'tx reverted' }) + ) + const cleared = reducer(failed, multiSwapCleared()) + expect(cleared.inFlight).toBeNull() + }) + + it('ignores stepSucceeded when no in-flight session', () => { + const state = reducer(undefined, multiSwapStepSucceeded({ index: 0 })) + expect(state).toEqual({ inFlight: null }) + }) + + it('ignores stepFailed when no in-flight session', () => { + const state = reducer(undefined, multiSwapStepFailed({ index: 0, errorMessage: 'x' })) + expect(state).toEqual({ inFlight: null }) + }) +}) diff --git a/src/dollarsSpend/slice.ts b/src/dollarsSpend/slice.ts new file mode 100644 index 000000000..6a574192d --- /dev/null +++ b/src/dollarsSpend/slice.ts @@ -0,0 +1,57 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { SpendStep } from 'src/dollarsSpend/types' + +interface InFlight { + plannedSteps: SpendStep[] + completedSteps: number + failedAtIndex: number | null + lastError: string | null +} + +export interface State { + inFlight: InFlight | null +} + +const initialState: State = { + inFlight: null, +} + +const slice = createSlice({ + name: 'dollarsSpend', + initialState, + reducers: { + multiSwapStarted(state, action: PayloadAction<{ steps: SpendStep[] }>) { + state.inFlight = { + plannedSteps: action.payload.steps, + completedSteps: 0, + failedAtIndex: null, + lastError: null, + } + }, + multiSwapStepSucceeded(state, action: PayloadAction<{ index: number }>) { + if (!state.inFlight) return + state.inFlight.completedSteps = action.payload.index + 1 + }, + multiSwapStepFailed(state, action: PayloadAction<{ index: number; errorMessage: string }>) { + if (!state.inFlight) return + state.inFlight.failedAtIndex = action.payload.index + state.inFlight.lastError = action.payload.errorMessage + }, + multiSwapCompleted(state) { + state.inFlight = null + }, + multiSwapCleared(state) { + state.inFlight = null + }, + }, +}) + +export const { + multiSwapStarted, + multiSwapStepSucceeded, + multiSwapStepFailed, + multiSwapCompleted, + multiSwapCleared, +} = slice.actions + +export default slice.reducer diff --git a/src/dollarsSpend/types.ts b/src/dollarsSpend/types.ts new file mode 100644 index 000000000..700e3aab3 --- /dev/null +++ b/src/dollarsSpend/types.ts @@ -0,0 +1,48 @@ +import BigNumber from 'bignumber.js' +import networkConfig from 'src/web3/networkConfig' + +// Synthetic tokenId used in pickers to represent the aggregated dollar bucket. +// Consumers detect this and route into the multi-step planner instead of using +// a real ERC-20 tokenId. Not a real token; never appears on-chain. +export const DOLARES_VIRTUAL_TOKEN_ID = 'virtual:dolares' + +// Spend priority. Index 0 is consumed first; later items only fire when earlier +// ones are exhausted (or below Squid minAmount). Tied to the wallet strategy: +// spend the least-liquid / most-regulated stables first so USDT is kept +// available as long as possible. Order is a constant; trivial to change. +// +// NOTE: this is an array of tokenId getters because networkConfig is read +// lazily (some tokens are mainnet-only and resolve to '' on Sepolia). +export const SPEND_ORDER: ReadonlyArray = [ + 'usatTokenId', + 'usdmTokenId', + 'usdcTokenId', + 'usdtTokenId', +] as const + +export type DollarSymbol = 'USAT' | 'USDm' | 'USDC' | 'USDT' + +export interface SpendStep { + tokenId: string + symbol: DollarSymbol + amountUsd: BigNumber // USD value of this step (priceUsd * tokenAmount) + amountTokenWhole: BigNumber // amount in token's whole units (BigNumber, decimal) +} + +export interface MultiSwapPlan { + steps: SpendStep[] + // > 0 when total dollar balance cannot meet requestedUsd. + // Equals requestedUsd minus the sum of step.amountUsd. + shortfall: BigNumber +} + +export interface DollarTokenBalanceSnapshot { + tokenId: string + symbol: DollarSymbol + balance: BigNumber // whole-units BigNumber + priceUsd: BigNumber // USD per token (e.g., 0.998) + // Smallest amount accepted by Squid for this token's swap, in USD. + // Tokens with `balance * priceUsd < minAmountUsd` are skipped (dust filter). + // 0 means "no minimum known yet" - planner treats it as 0 (no filter). + minAmountUsd: BigNumber +} diff --git a/src/dollarsSpend/useDollarBalanceSnapshots.test.tsx b/src/dollarsSpend/useDollarBalanceSnapshots.test.tsx new file mode 100644 index 000000000..f8ccac32c --- /dev/null +++ b/src/dollarsSpend/useDollarBalanceSnapshots.test.tsx @@ -0,0 +1,79 @@ +import * as React from 'react' +import { renderHook } from '@testing-library/react-native' +import { Provider } from 'react-redux' +import { useDollarBalanceSnapshots } from 'src/dollarsSpend/useDollarBalanceSnapshots' +import { createMockStore } from 'test/utils' +import networkConfig from 'src/web3/networkConfig' + +const renderWithStore = (hook: () => T, storeState: object) => { + const store = createMockStore(storeState) + return renderHook(hook, { + wrapper: ({ children }) => {children}, + }) +} + +describe('useDollarBalanceSnapshots', () => { + it('returns one snapshot per dollar token with priceUsd and balance', () => { + const storeState = { + tokens: { + tokenBalances: { + [networkConfig.usdtTokenId]: { + tokenId: networkConfig.usdtTokenId, + networkId: networkConfig.defaultNetworkId, + symbol: 'USDT', + balance: '2', + priceUsd: '1', + decimals: 6, + address: '0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e', + priceFetchedAt: Date.now(), + }, + [networkConfig.usdcTokenId]: { + tokenId: networkConfig.usdcTokenId, + networkId: networkConfig.defaultNetworkId, + symbol: 'USDC', + balance: '1', + priceUsd: '1', + decimals: 6, + address: '0xcEBA9300f2b948710d2653dD7B07f33A8B32118C', + priceFetchedAt: Date.now(), + }, + }, + }, + } + const { result } = renderWithStore(useDollarBalanceSnapshots, storeState) + const symbols = result.current.map((s) => s.symbol).sort() + expect(symbols).toContain('USDT') + expect(symbols).toContain('USDC') + const usdt = result.current.find((s) => s.symbol === 'USDT') + expect(usdt?.balance.toString()).toBe('2') + expect(usdt?.priceUsd.toString()).toBe('1') + }) + + it('returns empty array when no dollar tokens have positive balance', () => { + const { result } = renderWithStore(useDollarBalanceSnapshots, { + tokens: { tokenBalances: {} }, + }) + expect(result.current).toEqual([]) + }) + + it('skips dollar tokens with null priceUsd', () => { + const storeState = { + tokens: { + tokenBalances: { + [networkConfig.usdtTokenId]: { + tokenId: networkConfig.usdtTokenId, + networkId: networkConfig.defaultNetworkId, + symbol: 'USDT', + balance: '2', + priceUsd: null, + decimals: 6, + address: '0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e', + priceFetchedAt: Date.now(), + }, + }, + }, + } + const { result } = renderWithStore(useDollarBalanceSnapshots, storeState) + expect(result.current).toEqual([]) + }) +}) diff --git a/src/dollarsSpend/useDollarBalanceSnapshots.ts b/src/dollarsSpend/useDollarBalanceSnapshots.ts new file mode 100644 index 000000000..428f6dee6 --- /dev/null +++ b/src/dollarsSpend/useDollarBalanceSnapshots.ts @@ -0,0 +1,44 @@ +import BigNumber from 'bignumber.js' +import { useMemo } from 'react' +import { DollarSymbol, DollarTokenBalanceSnapshot } from 'src/dollarsSpend/types' +import { useSelector } from 'src/redux/hooks' +import { tokensByIdSelector } from 'src/tokens/selectors' +import { getSupportedNetworkIdsForTokenBalances } from 'src/tokens/utils' +import networkConfig from 'src/web3/networkConfig' + +// Maps a tokenId to the canonical DollarSymbol used in the planner. +function tokenIdToSymbol(tokenId: string): DollarSymbol | null { + if (tokenId === networkConfig.usdtTokenId) return 'USDT' + if (tokenId === networkConfig.usdcTokenId) return 'USDC' + if (tokenId === networkConfig.usdmTokenId) return 'USDm' + if (tokenId === networkConfig.usatTokenId && networkConfig.usatTokenId) return 'USAT' + return null +} + +// Builds the snapshots array that planSpend consumes. +// minAmountUsd defaults to 0 (no dust filter at this layer); upstream callers +// can override after fetching Squid's minAmount once the quote returns. +export function useDollarBalanceSnapshots(): DollarTokenBalanceSnapshot[] { + const supportedNetworkIds = getSupportedNetworkIdsForTokenBalances() + const tokensById = useSelector((state) => tokensByIdSelector(state, supportedNetworkIds)) + + return useMemo(() => { + const out: DollarTokenBalanceSnapshot[] = [] + for (const token of Object.values(tokensById)) { + if (!token) continue + const symbol = tokenIdToSymbol(token.tokenId) + if (!symbol) continue + if (token.priceUsd === null || token.priceUsd === undefined) continue + const balance = token.balance ?? new BigNumber(0) + if (balance.lte(0)) continue + out.push({ + tokenId: token.tokenId, + symbol, + balance, + priceUsd: new BigNumber(token.priceUsd), + minAmountUsd: new BigNumber(0), + }) + } + return out + }, [tokensById]) +} diff --git a/src/dollarsSpend/useMultiSwapQuote.test.tsx b/src/dollarsSpend/useMultiSwapQuote.test.tsx new file mode 100644 index 000000000..ec737f95c --- /dev/null +++ b/src/dollarsSpend/useMultiSwapQuote.test.tsx @@ -0,0 +1,97 @@ +import { renderHook, waitFor } from '@testing-library/react-native' +import BigNumber from 'bignumber.js' +import React from 'react' +import { Provider } from 'react-redux' +import { useMultiSwapQuote } from 'src/dollarsSpend/useMultiSwapQuote' +import { SpendStep } from 'src/dollarsSpend/types' +import { createMockStore } from 'test/utils' + +jest.mock('src/swap/useSwapQuote', () => ({ + ...jest.requireActual('src/swap/useSwapQuote'), + fetchSwapQuote: jest.fn(), +})) + +const { fetchSwapQuote } = jest.requireMock('src/swap/useSwapQuote') + +const stepUsat: SpendStep = { + tokenId: 'celo-mainnet:usat', + symbol: 'USAT', + amountUsd: new BigNumber(30), + amountTokenWhole: new BigNumber(30), +} +const stepUsdm: SpendStep = { + tokenId: 'celo-mainnet:usdm', + symbol: 'USDm', + amountUsd: new BigNumber(50), + amountTokenWhole: new BigNumber(50), +} + +const renderWithStore = (hook: () => T) => { + const store = createMockStore({ + web3: { account: '0x0000000000000000000000000000000000000001' }, + }) + return renderHook(hook, { + wrapper: ({ children }) => {children}, + }) +} + +describe('useMultiSwapQuote', () => { + beforeEach(() => { + fetchSwapQuote.mockReset() + }) + + it('returns loading=true while quotes are fetching', () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + fetchSwapQuote.mockImplementation(() => new Promise(() => {})) + const { result } = renderWithStore(() => + useMultiSwapQuote([stepUsat, stepUsdm], 'celo-mainnet:copm') + ) + expect(result.current.loading).toBe(true) + }) + + it('aggregates totalInUsd and totalOutToken when all quotes resolve', async () => { + fetchSwapQuote.mockImplementation(async (args: { fromTokenId: string }) => { + const out = args.fromTokenId.includes('usdm') ? 204_000 : 122_400 + return { + fromTokenId: args.fromTokenId, + swapAmount: { FROM: new BigNumber(30), TO: new BigNumber(out) }, + price: String(out / 30), + } + }) + + const { result } = renderWithStore(() => + useMultiSwapQuote([stepUsat, stepUsdm], 'celo-mainnet:copm') + ) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.totalInUsd.toString()).toBe('80') + expect(result.current.totalOutToken.toString()).toBe('326400') + expect(result.current.perStepQuotes).toHaveLength(2) + expect(result.current.error).toBeUndefined() + }) + + it('surfaces an error if any quote fetch fails', async () => { + fetchSwapQuote.mockImplementation(async (args: { fromTokenId: string }) => { + if (args.fromTokenId.includes('usdm')) { + throw new Error('Squid 500') + } + return { + fromTokenId: args.fromTokenId, + swapAmount: { FROM: new BigNumber(30), TO: new BigNumber(122_400) }, + } + }) + + const { result } = renderWithStore(() => + useMultiSwapQuote([stepUsat, stepUsdm], 'celo-mainnet:copm') + ) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.error?.message).toContain('Squid 500') + }) + + it('returns zero totals and no quotes when steps is empty', async () => { + const { result } = renderWithStore(() => useMultiSwapQuote([], 'celo-mainnet:copm')) + expect(result.current.loading).toBe(false) + expect(result.current.totalInUsd.toString()).toBe('0') + expect(result.current.totalOutToken.toString()).toBe('0') + expect(result.current.perStepQuotes).toEqual([]) + }) +}) diff --git a/src/dollarsSpend/useMultiSwapQuote.ts b/src/dollarsSpend/useMultiSwapQuote.ts new file mode 100644 index 000000000..b1482e2e1 --- /dev/null +++ b/src/dollarsSpend/useMultiSwapQuote.ts @@ -0,0 +1,80 @@ +import BigNumber from 'bignumber.js' +import { useEffect, useState } from 'react' +import { SpendStep } from 'src/dollarsSpend/types' +import { useSelector } from 'src/redux/hooks' +import { FetchSwapQuoteResult, fetchSwapQuote } from 'src/swap/useSwapQuote' +import { walletAddressSelector } from 'src/web3/selectors' + +interface UseMultiSwapQuoteResult { + loading: boolean + totalInUsd: BigNumber + totalOutToken: BigNumber + perStepQuotes: FetchSwapQuoteResult[] + error?: Error +} + +export function useMultiSwapQuote(steps: SpendStep[], toTokenId: string): UseMultiSwapQuoteResult { + const walletAddress = useSelector(walletAddressSelector) + const [loading, setLoading] = useState(steps.length > 0) + const [error, setError] = useState(undefined) + const [perStepQuotes, setPerStepQuotes] = useState([]) + const [totalOutToken, setTotalOutToken] = useState(new BigNumber(0)) + + const totalInUsd = steps.reduce((sum, s) => sum.plus(s.amountUsd), new BigNumber(0)) + + // Depend on the serialized list of (tokenId, amount) pairs so the effect + // re-fires only when the steps actually change. + const stepsKey = steps.map((s) => `${s.tokenId}:${s.amountTokenWhole.toString()}`).join(',') + + useEffect(() => { + if (steps.length === 0 || !walletAddress) { + setLoading(false) + setPerStepQuotes([]) + setTotalOutToken(new BigNumber(0)) + setError(undefined) + return + } + + let cancelled = false + setLoading(true) + setError(undefined) + + Promise.all( + steps.map((step) => + fetchSwapQuote({ + fromTokenId: step.tokenId, + toTokenId, + amount: step.amountTokenWhole.toString(), + walletAddress, + }) + ) + ) + .then((results) => { + if (cancelled) return + const sumOut = results.reduce((sum, q) => sum.plus(q.swapAmount.TO), new BigNumber(0)) + setPerStepQuotes(results) + setTotalOutToken(sumOut) + setLoading(false) + }) + .catch((err) => { + if (cancelled) return + setError(err instanceof Error ? err : new Error(String(err))) + setPerStepQuotes([]) + setTotalOutToken(new BigNumber(0)) + setLoading(false) + }) + + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stepsKey, toTokenId, walletAddress]) + + return { + loading, + totalInUsd, + totalOutToken, + perStepQuotes, + error, + } +} diff --git a/src/redux/migrations.ts b/src/redux/migrations.ts index 82ec7703e..9e0977574 100755 --- a/src/redux/migrations.ts +++ b/src/redux/migrations.ts @@ -2060,4 +2060,13 @@ export const migrations = { } return state }, + 247: (state: any) => { + // Phase 3 foundation: introduce the dollarsSpend slice for multi-step + // Dolares swap orchestration. No prior shape exists; seed with the + // initial state so the RootState schema validates. + return { + ...state, + dollarsSpend: { inFlight: null }, + } + }, } diff --git a/src/redux/reducersList.ts b/src/redux/reducersList.ts index 2ade039ec..6ab7c6019 100755 --- a/src/redux/reducersList.ts +++ b/src/redux/reducersList.ts @@ -4,6 +4,7 @@ import bucksPayReducer from 'src/buckspay/slice' import goldReducer from 'src/gold/slice' import { appReducer as app } from 'src/app/reducers' import dappsReducer from 'src/dapps/slice' +import dollarsSpendReducer from 'src/dollarsSpend/slice' import earnReducer from 'src/earn/slice' import { reducer as fiatExchanges } from 'src/fiatExchanges/reducer' import fiatConnectReducer from 'src/fiatconnect/slice' @@ -45,6 +46,7 @@ export const reducersList = { walletConnect, tokens: tokenReducer, dapps: dappsReducer, + dollarsSpend: dollarsSpendReducer, fiatConnect: fiatConnectReducer, swap: swapReducer, positions: positionsReducer, diff --git a/src/redux/sagas.ts b/src/redux/sagas.ts index 87090a936..11aa518f1 100755 --- a/src/redux/sagas.ts +++ b/src/redux/sagas.ts @@ -45,6 +45,7 @@ import { import { recipientsSaga } from 'src/recipients/saga' import { sendSaga } from 'src/send/saga' // import { sentrySaga } from 'src/sentry/saga' // Commented out - Sentry disabled +import { dollarsSpendSaga } from 'src/dollarsSpend/saga' import { swapSaga } from 'src/swap/saga' import { tokensSaga } from 'src/tokens/saga' import { setTokenBalances } from 'src/tokens/slice' @@ -144,6 +145,7 @@ export function* rootSaga() { yield* spawn(dappsSaga) yield* spawn(fiatConnectSaga) yield* spawn(swapSaga) + yield* spawn(dollarsSpendSaga) yield* spawn(keylessBackupSaga) yield* spawn(nftsSaga) yield* spawn(priceHistorySaga) diff --git a/src/redux/store.test.ts b/src/redux/store.test.ts index 60f6bc101..56f594d31 100755 --- a/src/redux/store.test.ts +++ b/src/redux/store.test.ts @@ -143,7 +143,7 @@ describe('store state', () => { { "_persist": { "rehydrated": true, - "version": 246, + "version": 247, }, "account": { "acceptedTerms": false, @@ -237,6 +237,9 @@ describe('store state', () => { "mostPopularDappIds": [], "recentDappIds": [], }, + "dollarsSpend": { + "inFlight": null, + }, "earn": { "depositStatus": "idle", "poolInfo": undefined, diff --git a/src/redux/store.ts b/src/redux/store.ts index eea1ee26f..4f9c0fa02 100755 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -26,7 +26,7 @@ const persistConfig: PersistConfig = { key: 'root', // default is -1, increment as we make migrations // See https://github.com/valora-inc/wallet/tree/main/WALLET.md#redux-state-migration - version: 246, + version: 247, keyPrefix: `reduxStore-`, // the redux-persist default is `persist:` which doesn't work with some file systems. storage: FSStorage(), blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup', transactionFeedV2Api.reducerPath], diff --git a/src/swap/useSwapQuote.ts b/src/swap/useSwapQuote.ts index dcd8da253..74d9dd72e 100755 --- a/src/swap/useSwapQuote.ts +++ b/src/swap/useSwapQuote.ts @@ -28,6 +28,77 @@ const DECREASED_SWAP_AMOUNT_GAS_FEE_MULTIPLIER = 1.2 export const NO_QUOTE_ERROR_MESSAGE = 'No quote available' +export interface FetchSwapQuoteArgs { + fromTokenId: string + toTokenId: string + /** Sell amount in whole token units (not wei) */ + amount: string + walletAddress: string + slippagePercentage?: string +} + +export interface FetchSwapQuoteResult { + fromTokenId: string + toTokenId: string + /** Raw sell amount in whole token units */ + swapAmount: { FROM: BigNumber; TO: BigNumber } + price: string + provider: string + estimatedPriceImpact: string | null +} + +/** + * Lightweight quote fetcher for price discovery only - no tx preparation. + * Used by useMultiSwapQuote to fetch N parallel quotes without React state. + */ +export async function fetchSwapQuote(args: FetchSwapQuoteArgs): Promise { + const { fromTokenId, toTokenId, amount, walletAddress, slippagePercentage = '0.5' } = args + + // Token IDs are in the form "networkId:0xaddress" + const fromAddress = fromTokenId.split(':')[1] + const toAddress = toTokenId.split(':')[1] + const fromNetworkId = fromTokenId.split(':')[0] + const toNetworkId = toTokenId.split(':')[0] + + const params: Record = { + ...(toAddress && { buyToken: toAddress }), + buyIsNative: 'false', + buyNetworkId: toNetworkId, + ...(fromAddress && { sellToken: fromAddress }), + sellIsNative: 'false', + sellNetworkId: fromNetworkId, + sellAmount: amount, + userAddress: walletAddress, + slippagePercentage, + } + const queryParams = new URLSearchParams(params).toString() + const requestUrl = `${networkConfig.getSwapQuoteUrl}?${queryParams}` + const response = await fetch(requestUrl) + + if (!response.ok) { + throw new Error(await response.text()) + } + + const quote: FetchQuoteResponse = await response.json() + + if (!quote.unvalidatedSwapTransaction) { + throw new Error(NO_QUOTE_ERROR_MESSAGE) + } + + const tx = quote.unvalidatedSwapTransaction + return { + fromTokenId, + toTokenId, + swapAmount: { + FROM: new BigNumber(tx.sellAmount), + TO: new BigNumber(tx.buyAmount), + }, + price: tx.price, + provider: quote.details.swapProvider, + estimatedPriceImpact: tx.estimatedPriceImpact, + } +} + interface BaseQuoteResult { swapType: SwapType toTokenId: string diff --git a/test/RootStateSchema.json b/test/RootStateSchema.json index b475c8736..1bf020ef9 100755 --- a/test/RootStateSchema.json +++ b/test/RootStateSchema.json @@ -599,6 +599,51 @@ ], "type": "object" }, + "BigNumber": { + "additionalProperties": false, + "properties": { + "_isBigNumber": { + "const": true, + "description": "Used internally to identify a BigNumber instance.", + "type": "boolean" + }, + "c": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "The coefficient of the value of this BigNumber, an array of base 1e14 integer numbers, or null." + }, + "e": { + "description": "The exponent of the value of this BigNumber, an integer number, -1000000000 to 1000000000, or null.", + "type": [ + "null", + "number" + ] + }, + "s": { + "description": "The sign of the value of this BigNumber, -1, 1, or null.", + "type": [ + "null", + "number" + ] + } + }, + "required": [ + "_isBigNumber", + "c", + "e", + "s" + ], + "type": "object" + }, "BigNumber.Instance": { "additionalProperties": {}, "properties": { @@ -826,6 +871,8 @@ "CELO", "COPm", "ETH", + "USAT", + "USDC", "USDT", "cEUR", "cREAL", @@ -1380,6 +1427,15 @@ ], "type": "object" }, + "DollarSymbol": { + "enum": [ + "USAT", + "USDC", + "USDT", + "USDm" + ], + "type": "string" + }, "E164NumberToAddressType": { "additionalProperties": { "anyOf": [ @@ -2172,6 +2228,39 @@ ], "type": "number" }, + "InFlight": { + "additionalProperties": false, + "properties": { + "completedSteps": { + "type": "number" + }, + "failedAtIndex": { + "type": [ + "null", + "number" + ] + }, + "lastError": { + "type": [ + "null", + "string" + ] + }, + "plannedSteps": { + "items": { + "$ref": "#/definitions/SpendStep" + }, + "type": "array" + } + }, + "required": [ + "completedSteps", + "failedAtIndex", + "lastError", + "plannedSteps" + ], + "type": "object" + }, "KeylessBackupDeleteStatus": { "enum": [ "Completed", @@ -4199,6 +4288,30 @@ ], "type": "object" }, + "SpendStep": { + "additionalProperties": false, + "properties": { + "amountTokenWhole": { + "$ref": "#/definitions/BigNumber" + }, + "amountUsd": { + "$ref": "#/definitions/BigNumber" + }, + "symbol": { + "$ref": "#/definitions/DollarSymbol" + }, + "tokenId": { + "type": "string" + } + }, + "required": [ + "amountTokenWhole", + "amountUsd", + "symbol", + "tokenId" + ], + "type": "object" + }, "StandbyTransaction": { "anyOf": [ { @@ -5403,6 +5516,25 @@ "type": "object" }, "State_17": { + "additionalProperties": false, + "properties": { + "inFlight": { + "anyOf": [ + { + "$ref": "#/definitions/InFlight" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "inFlight" + ], + "type": "object" + }, + "State_18": { "additionalProperties": false, "properties": { "attemptReturnUserFlowLoading": { @@ -5557,7 +5689,7 @@ ], "type": "object" }, - "State_18": { + "State_19": { "additionalProperties": false, "properties": { "currentSwap": { @@ -5588,7 +5720,27 @@ ], "type": "object" }, - "State_19": { + "State_2": { + "additionalProperties": false, + "properties": { + "connected": { + "type": "boolean" + }, + "rehydrated": { + "type": "boolean" + }, + "userLocationData": { + "$ref": "#/definitions/UserLocationData" + } + }, + "required": [ + "connected", + "rehydrated", + "userLocationData" + ], + "type": "object" + }, + "State_20": { "additionalProperties": false, "properties": { "earnPositionIds": { @@ -5639,27 +5791,7 @@ ], "type": "object" }, - "State_2": { - "additionalProperties": false, - "properties": { - "connected": { - "type": "boolean" - }, - "rehydrated": { - "type": "boolean" - }, - "userLocationData": { - "$ref": "#/definitions/UserLocationData" - } - }, - "required": [ - "connected", - "rehydrated", - "userLocationData" - ], - "type": "object" - }, - "State_20": { + "State_21": { "additionalProperties": false, "properties": { "appKeyshare": { @@ -5700,7 +5832,7 @@ ], "type": "object" }, - "State_22": { + "State_23": { "additionalProperties": { "additionalProperties": false, "properties": { @@ -5722,7 +5854,7 @@ }, "type": "object" }, - "State_23": { + "State_24": { "additionalProperties": false, "properties": { "claimStatus": { @@ -5764,7 +5896,7 @@ ], "type": "object" }, - "State_24": { + "State_25": { "additionalProperties": false, "properties": { "getHistoryStatus": { @@ -5848,7 +5980,7 @@ ], "type": "object" }, - "State_25": { + "State_26": { "additionalProperties": false, "properties": { "depositStatus": { @@ -5871,7 +6003,7 @@ ], "type": "object" }, - "State_26": { + "State_27": { "additionalProperties": false, "properties": { "bucksPayCode": { @@ -5940,7 +6072,7 @@ ], "type": "object" }, - "State_27": { + "State_28": { "additionalProperties": false, "properties": { "buyStatus": { @@ -7889,22 +8021,25 @@ "$ref": "#/definitions/State" }, "buckspay": { - "$ref": "#/definitions/State_26" + "$ref": "#/definitions/State_27" }, "dapps": { "$ref": "#/definitions/State_16" }, + "dollarsSpend": { + "$ref": "#/definitions/State_17" + }, "earn": { - "$ref": "#/definitions/State_25" + "$ref": "#/definitions/State_26" }, "fiatConnect": { - "$ref": "#/definitions/State_17" + "$ref": "#/definitions/State_18" }, "fiatExchanges": { "$ref": "#/definitions/State_13" }, "gold": { - "$ref": "#/definitions/State_27" + "$ref": "#/definitions/State_28" }, "home": { "$ref": "#/definitions/State_5" @@ -7919,10 +8054,10 @@ "$ref": "#/definitions/State_12" }, "jumpstart": { - "$ref": "#/definitions/State_23" + "$ref": "#/definitions/State_24" }, "keylessBackup": { - "$ref": "#/definitions/State_20" + "$ref": "#/definitions/State_21" }, "localCurrency": { "$ref": "#/definitions/State_11" @@ -7957,13 +8092,13 @@ "type": "object" }, "points": { - "$ref": "#/definitions/State_24" + "$ref": "#/definitions/State_25" }, "positions": { - "$ref": "#/definitions/State_19" + "$ref": "#/definitions/State_20" }, "priceHistory": { - "$ref": "#/definitions/State_22" + "$ref": "#/definitions/State_23" }, "recipients": { "$ref": "#/definitions/State_10" @@ -7972,7 +8107,7 @@ "$ref": "#/definitions/State_4" }, "swap": { - "$ref": "#/definitions/State_18" + "$ref": "#/definitions/State_19" }, "tokens": { "$ref": "#/definitions/State_15" @@ -7994,6 +8129,7 @@ "app", "buckspay", "dapps", + "dollarsSpend", "earn", "fiatConnect", "fiatExchanges", diff --git a/test/schemas.ts b/test/schemas.ts index 354840883..e02d8e4ff 100755 --- a/test/schemas.ts +++ b/test/schemas.ts @@ -3687,6 +3687,16 @@ export const v246Schema = { }, } +// Migration 247 seeds the new dollarsSpend slice (Phase 3 foundation). +export const v247Schema = { + ...v246Schema, + dollarsSpend: { inFlight: null }, + _persist: { + ...v246Schema._persist, + version: 247, + }, +} + export function getLatestSchema(): Partial { - return v246Schema as Partial + return v247Schema as Partial }