Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions knip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/dollarsSpend/index.ts
Original file line number Diff line number Diff line change
@@ -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'
133 changes: 133 additions & 0 deletions src/dollarsSpend/planSpend.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
57 changes: 57 additions & 0 deletions src/dollarsSpend/planSpend.ts
Original file line number Diff line number Diff line change
@@ -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<Record<SpendStep['symbol'], DollarTokenBalanceSnapshot>> = {}
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)),
}
}
133 changes: 133 additions & 0 deletions src/dollarsSpend/saga.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading