Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
236 changes: 236 additions & 0 deletions src/partners/revolut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import {
asArray,
asDate,
asNumber,
asObject,
asOptional,
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 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: asOptional(asString),
tx_hash: asOptional(asString),
country_code: asOptional(asString),
payment_method: asOptional(asString)
})

type RevolutTx = ReturnType<typeof asRevolutTx>

const asPreRevolutTx = asObject({
state: asString
})

const asRevolutResult = asObject({
transactions: asArray(asUnknown),
next_cursor: asOptional(asString)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
})

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 cursor: string | undefined
const seenCursors = new Set<string>()
let pageCount = 0

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 nextCursor = result.next_cursor
pageCount++

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

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

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

if (nextCursor === requestCursor || seenCursors.has(nextCursor)) {
datelog(
`Stopping Revolut pagination on repeated cursor ${nextCursor}`
)
break
Comment thread
cursor[bot] marked this conversation as resolved.
}

if (pageCount >= MAX_PAGES) {
datelog(`Stopping Revolut pagination after ${MAX_PAGES} pages`)
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
  }
}


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: tx.wallet_address,
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
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:
throw new Error(
`Unknown payment method: ${tx.payment_method} for ${tx.id}`
)
}
}
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
89 changes: 89 additions & 0 deletions test/revolut.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { expect } from 'chai'
import { describe, it } from 'mocha'

import { processRevolutTx } from '../src/partners/revolut'

const baseRawTx = {
id: 'revolut-order',
type: 'buy',
created_at: '2026-07-24T12:34:56.000Z',
fiat_amount: 125.5,
fiat_currency: 'usd',
crypto_amount: 0.01,
crypto_currency: 'btc',
wallet_address: 'bc1qwallet',
tx_hash: 'revolut-txid',
country_code: 'US',
payment_method: 'card',
state: 'completed'
}

describe('Revolut transaction mapping', function() {
it('maps buy orders as fiat deposits and crypto payouts', function() {
const standardTx = processRevolutTx(baseRawTx)

expect(standardTx).to.include({
orderId: 'revolut-order',
countryCode: 'US',
depositCurrency: 'USD',
depositAmount: 125.5,
direction: 'buy',
exchangeType: 'fiat',
paymentType: 'credit',
payoutTxid: 'revolut-txid',
payoutAddress: 'bc1qwallet',
payoutCurrency: 'BTC',
payoutAmount: 0.01,
status: 'complete',
isoDate: '2026-07-24T12:34:56.000Z',
timestamp: 1784896496,
usdValue: -1
})
expect(standardTx.depositTxid).equals(undefined)
})

it('maps sell orders as crypto deposits and fiat payouts', function() {
const standardTx = processRevolutTx({
...baseRawTx,
type: 'sell',
fiat_amount: 250,
fiat_currency: 'eur',
crypto_amount: 1.5,
crypto_currency: 'eth',
payment_method: 'bank_transfer'
})

expect(standardTx).to.include({
depositTxid: 'revolut-txid',
depositCurrency: 'ETH',
depositAmount: 1.5,
direction: 'sell',
paymentType: 'banktransfer',
payoutCurrency: 'EUR',
payoutAmount: 250
})
expect(standardTx.payoutTxid).equals(undefined)
})

for (const testCase of [
{ revolutMethod: undefined, paymentType: null },
{ revolutMethod: 'revolut', paymentType: 'revolut' },
{ revolutMethod: 'card', paymentType: 'credit' },
{ revolutMethod: 'bank_transfer', paymentType: 'banktransfer' },
{ revolutMethod: 'apple_pay', paymentType: 'applepay' },
{ revolutMethod: 'google_pay', paymentType: 'googlepay' }
]) {
it(`maps ${testCase.revolutMethod ?? 'missing'} payment method`, function() {
const rawTx: any = { ...baseRawTx }
if (testCase.revolutMethod == null) {
delete rawTx.payment_method
} else {
rawTx.payment_method = testCase.revolutMethod
}

const standardTx = processRevolutTx(rawTx)

expect(standardTx.paymentType).equals(testCase.paymentType)
})
}
})