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
111 changes: 98 additions & 13 deletions src/controllers/callbacks/opennode-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { timingSafeEqual } from 'crypto'

import { Request, Response } from 'express'

import { Invoice, InvoiceStatus } from '../../@types/invoice'
import { createLogger } from '../../factories/logger-factory'
import { fromOpenNodeInvoice } from '../../utils/transform'
import { createSettings } from '../../factories/settings-factory'
import { getRemoteAddress } from '../../utils/http'
import { hmacSha256 } from '../../utils/secret'
import { IController } from '../../@types/controllers'
import { IPaymentsService } from '../../@types/services'

Expand All @@ -13,15 +17,97 @@ export class OpenNodeCallbackController implements IController {
private readonly paymentsService: IPaymentsService,
) {}

// TODO: Validate
public async handleRequest(
request: Request,
response: Response,
) {
debug('request headers: %o', request.headers)
debug('request body: %O', request.body)
debug(
'request body metadata: hasId=%s hasHashedOrder=%s status=%s',
typeof request.body?.id === 'string',
typeof request.body?.hashed_order === 'string',
typeof request.body?.status === 'string' ? request.body.status : 'missing',
)

const settings = createSettings()
const remoteAddress = getRemoteAddress(request, settings)
const paymentProcessor = settings.payments?.processor

if (paymentProcessor !== 'opennode') {
debug('denied request from %s to /callbacks/opennode which is not the current payment processor', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

const invoice = fromOpenNodeInvoice(request.body)
const validStatuses = ['expired', 'refunded', 'unpaid', 'processing', 'underpaid', 'paid']

if (
!request.body
|| typeof request.body.id !== 'string'
|| typeof request.body.hashed_order !== 'string'
|| typeof request.body.status !== 'string'
|| !validStatuses.includes(request.body.status)
) {
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Bad Request')
return
}

const openNodeApiKey = process.env.OPENNODE_API_KEY
if (!openNodeApiKey) {
debug('OPENNODE_API_KEY is not configured; unable to verify OpenNode callback from %s', remoteAddress)
response
.status(500)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Internal Server Error')
return
}

const expectedBuf = hmacSha256(openNodeApiKey, request.body.id)
const actualHex = request.body.hashed_order
const expectedHexLength = expectedBuf.length * 2

if (
actualHex.length !== expectedHexLength
|| !/^[0-9a-f]+$/i.test(actualHex)
) {
debug('invalid hashed_order format from %s to /callbacks/opennode', remoteAddress)
response
.status(400)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('Bad Request')
return
}

const actualBuf = Buffer.from(actualHex, 'hex')

if (
!timingSafeEqual(expectedBuf, actualBuf)
) {
debug('unauthorized request from %s to /callbacks/opennode: hashed_order mismatch', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

const statusMap: Record<string, InvoiceStatus> = {
expired: InvoiceStatus.EXPIRED,
refunded: InvoiceStatus.EXPIRED,
unpaid: InvoiceStatus.PENDING,
processing: InvoiceStatus.PENDING,
underpaid: InvoiceStatus.PENDING,
paid: InvoiceStatus.COMPLETED,
}

const invoice: Pick<Invoice, 'id' | 'status'> = {
id: request.body.id,
status: statusMap[request.body.status],
}

debug('invoice', invoice)

Expand All @@ -34,26 +120,25 @@ export class OpenNodeCallbackController implements IController {
throw error
}

if (
updatedInvoice.status !== InvoiceStatus.COMPLETED
&& !updatedInvoice.confirmedAt
) {
if (updatedInvoice.status !== InvoiceStatus.COMPLETED) {
response
.status(200)
.send()

return
}

invoice.amountPaid = invoice.amountRequested
updatedInvoice.amountPaid = invoice.amountRequested
if (!updatedInvoice.confirmedAt) {
updatedInvoice.confirmedAt = new Date()
}
updatedInvoice.amountPaid = updatedInvoice.amountRequested

try {
await this.paymentsService.confirmInvoice({
id: invoice.id,
pubkey: invoice.pubkey,
id: updatedInvoice.id,
pubkey: updatedInvoice.pubkey,
status: updatedInvoice.status,
amountPaid: updatedInvoice.amountRequested,
amountPaid: updatedInvoice.amountPaid,
confirmedAt: updatedInvoice.confirmedAt,
})
await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice)
Expand Down
4 changes: 2 additions & 2 deletions src/routes/callbacks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { json, Router } from 'express'
import { json, Router, urlencoded } from 'express'

import { createLNbitsCallbackController } from '../../factories/controllers/lnbits-callback-controller-factory'
import { createNodelessCallbackController } from '../../factories/controllers/nodeless-callback-controller-factory'
Expand All @@ -16,6 +16,6 @@ router
(req as any).rawBody = buf
},
}), withController(createNodelessCallbackController))
.post('/opennode', json(), withController(createOpenNodeCallbackController))
.post('/opennode', urlencoded({ extended: false }), json(), withController(createOpenNodeCallbackController))

export default router
218 changes: 218 additions & 0 deletions test/unit/controllers/callbacks/opennode-callback-controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import chai, { expect } from 'chai'
import Sinon from 'sinon'
import sinonChai from 'sinon-chai'

import * as httpUtils from '../../../../src/utils/http'
import * as settingsFactory from '../../../../src/factories/settings-factory'

import { hmacSha256 } from '../../../../src/utils/secret'
import { InvoiceStatus } from '../../../../src/@types/invoice'
import { OpenNodeCallbackController } from '../../../../src/controllers/callbacks/opennode-callback-controller'

chai.use(sinonChai)

describe('OpenNodeCallbackController', () => {
let createSettingsStub: Sinon.SinonStub
let getRemoteAddressStub: Sinon.SinonStub
let updateInvoiceStatusStub: Sinon.SinonStub
let confirmInvoiceStub: Sinon.SinonStub
let sendInvoiceUpdateNotificationStub: Sinon.SinonStub
let statusStub: Sinon.SinonStub
let setHeaderStub: Sinon.SinonStub
let sendStub: Sinon.SinonStub
let controller: OpenNodeCallbackController
let request: any
let response: any
let previousOpenNodeApiKey: string | undefined

beforeEach(() => {
previousOpenNodeApiKey = process.env.OPENNODE_API_KEY
process.env.OPENNODE_API_KEY = 'test-api-key'

createSettingsStub = Sinon.stub(settingsFactory, 'createSettings').returns({
payments: { processor: 'opennode' },
} as any)
getRemoteAddressStub = Sinon.stub(httpUtils, 'getRemoteAddress').returns('127.0.0.1')

updateInvoiceStatusStub = Sinon.stub()
confirmInvoiceStub = Sinon.stub()
sendInvoiceUpdateNotificationStub = Sinon.stub()

controller = new OpenNodeCallbackController({
updateInvoiceStatus: updateInvoiceStatusStub,
confirmInvoice: confirmInvoiceStub,
sendInvoiceUpdateNotification: sendInvoiceUpdateNotificationStub,
} as any)

statusStub = Sinon.stub()
setHeaderStub = Sinon.stub()
sendStub = Sinon.stub()

response = {
send: sendStub,
setHeader: setHeaderStub,
status: statusStub,
}

statusStub.returns(response)
setHeaderStub.returns(response)
sendStub.returns(response)

request = {
body: {},
headers: {},
}
})

afterEach(() => {
getRemoteAddressStub.restore()
createSettingsStub.restore()

if (typeof previousOpenNodeApiKey === 'undefined') {
delete process.env.OPENNODE_API_KEY
} else {
process.env.OPENNODE_API_KEY = previousOpenNodeApiKey
}
})

it('rejects requests when OpenNode is not the configured payment processor', async () => {
createSettingsStub.returns({
payments: { processor: 'lnbits' },
} as any)

await controller.handleRequest(request, response)

expect(statusStub).to.have.been.calledOnceWithExactly(403)
expect(sendStub).to.have.been.calledOnceWithExactly('Forbidden')
expect(updateInvoiceStatusStub).not.to.have.been.called
})

it('returns bad request for malformed callback bodies', async () => {
request.body = {
id: 'invoice-id',
}

await controller.handleRequest(request, response)

expect(statusStub).to.have.been.calledOnceWithExactly(400)
expect(setHeaderStub).to.have.been.calledOnceWithExactly('content-type', 'text/plain; charset=utf8')
expect(sendStub).to.have.been.calledOnceWithExactly('Bad Request')
expect(updateInvoiceStatusStub).not.to.have.been.called
})

it('returns bad request for unknown status values', async () => {
request.body = {
hashed_order: 'some-hash',
id: 'invoice-id',
status: 'totally_made_up',
}

await controller.handleRequest(request, response)

expect(statusStub).to.have.been.calledOnceWithExactly(400)
expect(sendStub).to.have.been.calledOnceWithExactly('Bad Request')
expect(updateInvoiceStatusStub).not.to.have.been.called
})

it('returns internal server error when OPENNODE_API_KEY is missing', async () => {
delete process.env.OPENNODE_API_KEY
request.body = {
hashed_order: 'some-hash',
id: 'invoice-id',
status: 'paid',
}

await controller.handleRequest(request, response)

expect(statusStub).to.have.been.calledOnceWithExactly(500)
expect(setHeaderStub).to.have.been.calledOnceWithExactly('content-type', 'text/plain; charset=utf8')
expect(sendStub).to.have.been.calledOnceWithExactly('Internal Server Error')
expect(updateInvoiceStatusStub).not.to.have.been.called
})

it('returns bad request for malformed hashed_order', async () => {
request.body = {
hashed_order: 'invalid',
id: 'invoice-id',
status: 'paid',
}

await controller.handleRequest(request, response)

expect(statusStub).to.have.been.calledOnceWithExactly(400)
expect(setHeaderStub).to.have.been.calledOnceWithExactly('content-type', 'text/plain; charset=utf8')
expect(sendStub).to.have.been.calledOnceWithExactly('Bad Request')
expect(updateInvoiceStatusStub).not.to.have.been.called
})

it('rejects callbacks with mismatched hashed_order', async () => {
request.body = {
hashed_order: '0'.repeat(64),
id: 'invoice-id',
status: 'paid',
}

await controller.handleRequest(request, response)

expect(statusStub).to.have.been.calledOnceWithExactly(403)
expect(sendStub).to.have.been.calledOnceWithExactly('Forbidden')
expect(updateInvoiceStatusStub).not.to.have.been.called
})

it('accepts valid signed callbacks and processes the invoice update', async () => {
request.body = {
amount: 21,
created_at: '2026-04-11T00:00:00.000Z',
description: 'Admission fee',
hashed_order: hmacSha256('test-api-key', 'invoice-id').toString('hex'),
id: 'invoice-id',
lightning: {
expires_at: '2026-04-11T01:00:00.000Z',
payreq: 'lnbc1test',
},
order_id: 'pubkey',
status: 'unpaid',
}

updateInvoiceStatusStub.resolves({
confirmedAt: null,
status: InvoiceStatus.PENDING,
})

await controller.handleRequest(request, response)

expect(updateInvoiceStatusStub).to.have.been.calledOnce
expect(confirmInvoiceStub).not.to.have.been.called
expect(sendInvoiceUpdateNotificationStub).not.to.have.been.called
expect(statusStub).to.have.been.calledOnceWithExactly(200)
expect(sendStub).to.have.been.calledOnceWithExactly()
})

it('confirms and notifies on paid callbacks, setting confirmedAt when absent', async () => {
request.body = {
hashed_order: hmacSha256('test-api-key', 'invoice-id').toString('hex'),
id: 'invoice-id',
status: 'paid',
}

updateInvoiceStatusStub.resolves({
amountRequested: 1000n,
confirmedAt: null,
id: 'invoice-id',
pubkey: 'somepubkey',
status: InvoiceStatus.COMPLETED,
})
confirmInvoiceStub.resolves()
sendInvoiceUpdateNotificationStub.resolves()

await controller.handleRequest(request, response)

expect(updateInvoiceStatusStub).to.have.been.calledOnce
expect(confirmInvoiceStub).to.have.been.calledOnce
const confirmedAtArg = confirmInvoiceStub.firstCall.args[0].confirmedAt
expect(confirmedAtArg).to.be.instanceOf(Date)
expect(sendInvoiceUpdateNotificationStub).to.have.been.calledOnce
expect(statusStub).to.have.been.calledOnceWithExactly(200)
expect(sendStub).to.have.been.calledOnceWithExactly('OK')
})
})
Loading
Loading