Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/demo/partners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ export default {
type: 'fiat',
color: '#99A5DE'
},
revolut: {
type: 'fiat',
color: '#191C33'
},
safello: {
type: 'fiat',
color: deprecated
Expand Down
268 changes: 268 additions & 0 deletions src/partners/revolut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
import {
asArray,
asDate,
asMaybe,
asNumber,
asObject,
asString,
asUnknown,
asValue
} from 'cleaners'

import {
asStandardPluginParams,
EDGE_APP_START_DATE,
FiatPaymentType,
PartnerPlugin,
PluginParams,
PluginResult,
StandardTx
} from '../types'
import { datelog, retryFetch, smartIsoDateFromTimestamp, snooze } from '../util'

const asRevolutPaymentMethod = asMaybe(
asValue('revolut', 'card', 'bank_transfer', 'apple_pay', 'google_pay')
)

const asRevolutTx = asObject({
id: asString,
type: asValue('buy', 'sell'),
created_at: asDate,
fiat_amount: asNumber,
fiat_currency: asString,
crypto_amount: asNumber,
crypto_currency: asString,
wallet_address: asMaybe(asString),
tx_hash: asMaybe(asString),
country_code: asMaybe(asString),
payment_method: asRevolutPaymentMethod
})

type RevolutTx = ReturnType<typeof asRevolutTx>

const asPreRevolutTx = asObject({
state: asString
})

const asRevolutResult = asObject({
transactions: asArray(asUnknown),
next_cursor: asUnknown
})

const PLUGIN_START_DATE = '2024-01-01T00:00:00.000Z'
const QUERY_LOOKBACK = 1000 * 60 * 60 * 24 * 7 // 7 days
const QUERY_TIME_BLOCK_MS = QUERY_LOOKBACK
const QUERY_LIMIT = 100
const MAX_RETRIES = 5
const MAX_PAGES = 1000

export async function queryRevolut(
pluginParams: PluginParams
): Promise<PluginResult> {
const { settings, apiKeys } = asStandardPluginParams(pluginParams)
const { apiKey } = apiKeys

if (apiKey == null) {
return {
settings: { latestIsoDate: settings.latestIsoDate },
transactions: []
}
}

const now = Date.now()
let { latestIsoDate } = settings

if (latestIsoDate === EDGE_APP_START_DATE) {
latestIsoDate = PLUGIN_START_DATE
}

let startTime = new Date(latestIsoDate).getTime() - QUERY_LOOKBACK
if (startTime < 0) startTime = 0

const standardTxs: StandardTx[] = []
let retry = 0

while (true) {
const endTime = startTime + QUERY_TIME_BLOCK_MS

try {
let windowLatestIsoDate = latestIsoDate
const windowTxs: StandardTx[] = []
let cursor: string | undefined
const seenCursors = new Set<string>()
let pageCount = 0
let completedPagination = true

while (true) {
const requestCursor = cursor
const from = new Date(startTime).toISOString()
const to = new Date(endTime).toISOString()

let url = `https://api.revolut.com/partner/v1/transactions?from=${from}&to=${to}&limit=${QUERY_LIMIT}`
if (cursor != null) url += `&cursor=${cursor}`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor left unencoded in URL

Medium Severity

Pagination appends cursor with raw string concatenation, so reserved characters in a cursor token are not escaped. That can alter the query string, fetch the wrong page, or break pagination after the first page. paybis builds the same params through a query helper that encodes values.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 134bdb3. Configure here.


datelog(`Querying Revolut from:${from} to:${to}`)

const response = await retryFetch(url, {
headers: {
Authorization: `Bearer ${apiKey}`
}
})
if (!response.ok) {
const text = await response.text()
throw new Error(text)
}

const jsonObj = await response.json()
const result = asRevolutResult(jsonObj)
const rawNextCursor = result.next_cursor
const nextCursor =
typeof rawNextCursor === 'string' ? rawNextCursor : undefined
pageCount++

for (const rawTx of result.transactions) {
if (asPreRevolutTx(rawTx).state === 'completed') {
const standardTx = processRevolutTx(rawTx)
windowTxs.push(standardTx)
if (standardTx.isoDate > windowLatestIsoDate) {
windowLatestIsoDate = standardTx.isoDate
}
}
}

if (result.transactions.length > 0) {
datelog(`Revolut txs ${result.transactions.length}`)
}

if (rawNextCursor != null && typeof rawNextCursor !== 'string') {
datelog(`Stopping Revolut pagination on malformed next_cursor`)
completedPagination = false
break
}

if (
nextCursor != null &&
nextCursor !== '' &&
(nextCursor === requestCursor || seenCursors.has(nextCursor))
) {
datelog(
`Stopping Revolut pagination on repeated cursor ${nextCursor}`
)
completedPagination = false
break
}

if (nextCursor == null || nextCursor === '') {
break
}

if (pageCount >= MAX_PAGES) {
datelog(`Stopping Revolut pagination after ${MAX_PAGES} pages`)
completedPagination = false
break
}

seenCursors.add(nextCursor)
cursor = nextCursor
}
Comment on lines +91 to +167

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The inner pagination loop only exits when cursor == null. If the API returns a repeated, empty-string, or non-advancing cursor, this loop runs indefinitely until the outer promiseTimeout kills it—delaying progress and producing noisy retries.

Recommendation: Add a max-pages guard and track the previous cursor to break on non-advancing values:

const MAX_PAGES = 1000
let cursor: string | undefined
let pages = 0

while (true) {
  // ... existing fetch logic ...

  const prevCursor = cursor
  cursor = result.next_cursor

  // ... existing tx processing ...

  pages++
  if (cursor == null || cursor === prevCursor || pages >= MAX_PAGES) {
    break
  }
}


if (!completedPagination) {
standardTxs.push(...windowTxs)
break
}
Comment thread
cursor[bot] marked this conversation as resolved.

const watermarkTime = Math.min(endTime, now)
const windowEndIsoDate = new Date(watermarkTime).toISOString()
if (windowEndIsoDate > windowLatestIsoDate) {
windowLatestIsoDate = windowEndIsoDate
}

standardTxs.push(...windowTxs)
latestIsoDate = windowLatestIsoDate
startTime = endTime
if (endTime > now) {
break
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.
retry = 0
} catch (e) {
datelog(e)
retry++
if (retry <= MAX_RETRIES) {
datelog(`Snoozing ${60 * retry}s`)
await snooze(60000 * retry)
} else {
break
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
await snooze(1000)
}

return {
settings: { latestIsoDate },
transactions: standardTxs
}
}

export const revolut: PartnerPlugin = {
queryFunc: queryRevolut,
pluginName: 'Revolut',
pluginId: 'revolut'
}

export function processRevolutTx(rawTx: unknown): StandardTx {
const tx = asRevolutTx(rawTx)
const { isoDate, timestamp } = smartIsoDateFromTimestamp(
tx.created_at.getTime()
)

const direction = tx.type
const depositTxid = direction === 'sell' ? tx.tx_hash : undefined
const payoutTxid = direction === 'buy' ? tx.tx_hash : undefined

const standardTx: StandardTx = {
status: 'complete',
orderId: tx.id,
countryCode: tx.country_code ?? null,
depositTxid,
depositAddress: undefined,
depositCurrency:
direction === 'buy'
? tx.fiat_currency.toUpperCase()
: tx.crypto_currency.toUpperCase(),
depositAmount: direction === 'buy' ? tx.fiat_amount : tx.crypto_amount,
direction,
exchangeType: 'fiat',
paymentType: getRevolutPaymentType(tx),
payoutTxid,
payoutAddress: direction === 'buy' ? tx.wallet_address : undefined,
payoutCurrency:
direction === 'buy'
? tx.crypto_currency.toUpperCase()
: tx.fiat_currency.toUpperCase(),
payoutAmount: direction === 'buy' ? tx.crypto_amount : tx.fiat_amount,
timestamp,
isoDate,
usdValue: -1,
rawTx
}
return standardTx
}
Comment on lines +212 to +249

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This mapping logic is high-impact for reporting accuracy and currently untested. Consider adding focused unit tests covering buy/sell direction, deposit/payout currency assignment, txid placement, and payment-method conversion.

Recommendation: Add a test file such as test/partners/revolut.test.ts:

import { expect } from 'chai'
import { processRevolutTx, getRevolutPaymentType } from '../../src/partners/revolut'

describe('processRevolutTx', () => {
  const baseTx = {
    id: 'order-1',
    type: 'buy',
    state: 'completed',
    created_at: '2024-06-01T12:00:00.000Z',
    fiat_amount: 100,
    fiat_currency: 'usd',
    crypto_amount: 0.005,
    crypto_currency: 'btc',
    wallet_address: 'bc1abc',
    tx_hash: '0xabc',
    country_code: 'US',
    payment_method: 'card'
  }

  it('should map a buy tx with fiat deposit and crypto payout', () => {
    const result = processRevolutTx(baseTx)
    expect(result.direction).to.equal('buy')
    expect(result.depositCurrency).to.equal('USD')
    expect(result.payoutCurrency).to.equal('BTC')
    expect(result.payoutTxid).to.equal('0xabc')
    expect(result.depositTxid).to.be.undefined
  })

  it('should map a sell tx with crypto deposit and fiat payout', () => {
    const result = processRevolutTx({ ...baseTx, type: 'sell' })
    expect(result.direction).to.equal('sell')
    expect(result.depositCurrency).to.equal('BTC')
    expect(result.payoutCurrency).to.equal('USD')
    expect(result.depositTxid).to.equal('0xabc')
    expect(result.payoutTxid).to.be.undefined
  })
})


function getRevolutPaymentType(tx: RevolutTx): FiatPaymentType | null {
switch (tx.payment_method) {
case undefined:
return null
case 'revolut':
return 'revolut'
case 'card':
return 'credit'
case 'bank_transfer':
return 'banktransfer'
case 'apple_pay':
return 'applepay'
case 'google_pay':
return 'googlepay'
default:
return null
}
}
2 changes: 2 additions & 0 deletions src/queryEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { lifi } from './partners/lifi'
import { moonpay } from './partners/moonpay'
import { paybis } from './partners/paybis'
import { paytrie } from './partners/paytrie'
import { revolut } from './partners/revolut'
import { safello } from './partners/safello'
import { sideshift } from './partners/sideshift'
import { simplex } from './partners/simplex'
Expand Down Expand Up @@ -60,6 +61,7 @@ const plugins = [
moonpay,
paybis,
paytrie,
revolut,
safello,
sideshift,
simplex,
Expand Down
Loading