diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 5635812e22..4bfc8ba591 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -1694,12 +1694,10 @@ export function setupAPIRoutes(app: express.Application, config: Config): void { ); // lightning - pay invoice - app.post( - '/api/v2/:coin/wallet/:id/lightning/payment', - parseBody, + router.post('express.v2.wallet.lightningPayment', [ prepareBitGo(config), - promiseWrapper(handlePayLightningInvoice) - ); + typedPromiseWrapper(handlePayLightningInvoice), + ]); // lightning - onchain withdrawal router.post('express.v2.wallet.lightningWithdraw', [ diff --git a/modules/express/src/lightning/lightningInvoiceRoutes.ts b/modules/express/src/lightning/lightningInvoiceRoutes.ts index 8b10202c40..2f71742d28 100644 --- a/modules/express/src/lightning/lightningInvoiceRoutes.ts +++ b/modules/express/src/lightning/lightningInvoiceRoutes.ts @@ -2,6 +2,7 @@ import * as express from 'express'; import { ApiResponseError } from '../errors'; import { CreateInvoiceBody, getLightningWallet, Invoice, SubmitPaymentParams } from '@bitgo/abstract-lightning'; import { decodeOrElse } from '@bitgo/sdk-core'; +import { ExpressApiRouteRequest } from '../typedRoutes/api'; export async function handleCreateLightningInvoice(req: express.Request): Promise { const bitgo = req.bitgo; @@ -17,14 +18,16 @@ export async function handleCreateLightningInvoice(req: express.Request): Promis return Invoice.encode(await lightningWallet.createInvoice(params)); } -export async function handlePayLightningInvoice(req: express.Request): Promise { +export async function handlePayLightningInvoice( + req: ExpressApiRouteRequest<'express.v2.wallet.lightningPayment', 'post'> +): Promise { const bitgo = req.bitgo; const params = decodeOrElse(SubmitPaymentParams.name, SubmitPaymentParams, req.body, (error) => { throw new ApiResponseError(`Invalid request body to pay lightning invoice`, 400); }); - const coin = bitgo.coin(req.params.coin); - const wallet = await coin.wallets().get({ id: req.params.id }); + const coin = bitgo.coin(req.decoded.coin); + const wallet = await coin.wallets().get({ id: req.decoded.id }); const lightningWallet = getLightningWallet(wallet); return await lightningWallet.payInvoice(params); diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index d59c479bc6..fab3da6d2b 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -41,6 +41,7 @@ import { PostCoinSign } from './v2/coinSign'; import { PostSendCoins } from './v2/sendCoins'; import { PostGenerateShareTSS } from './v2/generateShareTSS'; import { PostOfcExtSignPayload } from './v2/ofcExtSignPayload'; +import { PostLightningWalletPayment } from './v2/lightningPayment'; import { PostLightningWalletWithdraw } from './v2/lightningWithdraw'; // Too large types can cause the following error @@ -190,6 +191,12 @@ export const ExpressKeychainChangePasswordApiSpec = apiSpec({ }, }); +export const ExpressLightningWalletPaymentApiSpec = apiSpec({ + 'express.v2.wallet.lightningPayment': { + post: PostLightningWalletPayment, + }, +}); + export const ExpressLightningGetStateApiSpec = apiSpec({ 'express.lightning.getState': { get: GetLightningState, @@ -285,6 +292,7 @@ export type ExpressApi = typeof ExpressPingApiSpec & typeof ExpressV2WalletCreateAddressApiSpec & typeof ExpressKeychainLocalApiSpec & typeof ExpressKeychainChangePasswordApiSpec & + typeof ExpressLightningWalletPaymentApiSpec & typeof ExpressLightningGetStateApiSpec & typeof ExpressLightningInitWalletApiSpec & typeof ExpressLightningUnlockWalletApiSpec & @@ -319,6 +327,7 @@ export const ExpressApi: ExpressApi = { ...ExpressV2WalletCreateAddressApiSpec, ...ExpressKeychainLocalApiSpec, ...ExpressKeychainChangePasswordApiSpec, + ...ExpressLightningWalletPaymentApiSpec, ...ExpressLightningGetStateApiSpec, ...ExpressLightningInitWalletApiSpec, ...ExpressLightningUnlockWalletApiSpec, diff --git a/modules/express/src/typedRoutes/api/v2/lightningPayment.ts b/modules/express/src/typedRoutes/api/v2/lightningPayment.ts new file mode 100644 index 0000000000..9f1816d501 --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/lightningPayment.ts @@ -0,0 +1,241 @@ +import * as t from 'io-ts'; +import { httpRoute, httpRequest, optional } from '@api-ts/io-ts-http'; +import { BigIntFromString } from 'io-ts-types'; +import { BitgoExpressError } from '../../schemas/error'; + +/** + * Path parameters for lightning payment API + */ +export const LightningPaymentParams = { + /** The coin identifier (e.g., 'tlnbtc', 'lnbtc') */ + coin: t.string, + /** The wallet ID */ + id: t.string, +} as const; + +/** + * Request body for paying a lightning invoice + */ +export const LightningPaymentRequestBody = { + /** The BOLT #11 encoded lightning invoice to pay */ + invoice: t.string, + /** The wallet passphrase to decrypt signing keys */ + passphrase: t.string, + /** Amount to pay in millisatoshis (required for zero-amount invoices) */ + amountMsat: optional(BigIntFromString), + /** Maximum fee limit in millisatoshis */ + feeLimitMsat: optional(BigIntFromString), + /** Fee limit as a ratio of payment amount (e.g., 0.01 for 1%) */ + feeLimitRatio: optional(t.number), + /** Custom sequence ID for tracking this payment */ + sequenceId: optional(t.string), + /** Comment or memo for this payment (not sent to recipient) */ + comment: optional(t.string), +} as const; + +/** + * Payment status on the Lightning Network + */ +const PaymentStatus = t.union([t.literal('in_flight'), t.literal('settled'), t.literal('failed')]); + +/** + * Payment failure reasons + */ +const PaymentFailureReason = t.union([ + t.literal('TIMEOUT'), + t.literal('NO_ROUTE'), + t.literal('ERROR'), + t.literal('INCORRECT_PAYMENT_DETAILS'), + t.literal('INSUFFICIENT_BALANCE'), + t.literal('INSUFFICIENT_WALLET_BALANCE'), + t.literal('EXCESS_WALLET_BALANCE'), + t.literal('INVOICE_EXPIRED'), + t.literal('PAYMENT_ALREADY_SETTLED'), + t.literal('PAYMENT_ALREADY_IN_FLIGHT'), + t.literal('TRANSIENT_ERROR_RETRY_LATER'), + t.literal('CANCELED'), + t.literal('FORCE_FAILED'), +]); + +/** + * Lightning Network payment status details + */ +const LndCreatePaymentResponse = t.intersection([ + t.type({ + /** Current payment status */ + status: PaymentStatus, + /** Payment hash identifying this payment */ + paymentHash: t.string, + }), + t.partial({ + /** Internal BitGo payment ID */ + paymentId: t.string, + /** Payment preimage (present when settled) */ + paymentPreimage: t.string, + /** Actual amount paid in millisatoshis */ + amountMsat: t.string, + /** Actual fee paid in millisatoshis */ + feeMsat: t.string, + /** Failure reason (present when failed) */ + failureReason: PaymentFailureReason, + }), +]); + +/** + * Transaction request state + */ +const TxRequestState = t.union([ + t.literal('pendingCommitment'), + t.literal('pendingApproval'), + t.literal('canceled'), + t.literal('rejected'), + t.literal('initialized'), + t.literal('pendingDelivery'), + t.literal('delivered'), + t.literal('pendingUserSignature'), + t.literal('signed'), +]); + +/** + * Pending approval state + */ +const PendingApprovalState = t.union([ + t.literal('pending'), + t.literal('awaitingSignature'), + t.literal('pendingBitGoAdminApproval'), + t.literal('pendingIdVerification'), + t.literal('pendingCustodianApproval'), + t.literal('pendingFinalApproval'), + t.literal('approved'), + t.literal('processing'), + t.literal('rejected'), +]); + +/** + * Pending approval type + */ +const PendingApprovalType = t.union([ + t.literal('userChangeRequest'), + t.literal('transactionRequest'), + t.literal('policyRuleRequest'), + t.literal('updateApprovalsRequiredRequest'), + t.literal('transactionRequestFull'), +]); + +/** + * Transaction request details within pending approval info + */ +const TransactionRequestDetails = t.intersection([ + t.type({ + /** Coin-specific transaction details */ + coinSpecific: t.record(t.string, t.unknown), + /** Recipients of the transaction */ + recipients: t.unknown, + /** Build parameters for the transaction */ + buildParams: t.intersection([ + t.partial({ + /** Type of transaction */ + type: t.union([t.literal('fanout'), t.literal('consolidate')]), + }), + t.record(t.string, t.unknown), + ]), + }), + t.partial({ + /** Source wallet for the transaction */ + sourceWallet: t.string, + }), +]); + +/** + * Pending approval information + */ +const PendingApprovalInfo = t.intersection([ + t.type({ + /** Type of pending approval */ + type: PendingApprovalType, + }), + t.partial({ + /** Transaction request details (for transaction-related approvals) */ + transactionRequest: TransactionRequestDetails, + }), +]); + +/** + * Pending approval details + */ +const PendingApproval = t.intersection([ + t.type({ + /** Pending approval ID */ + id: t.string, + /** Approval state */ + state: PendingApprovalState, + /** User ID of the approval creator */ + creator: t.string, + /** Pending approval information */ + info: PendingApprovalInfo, + }), + t.partial({ + /** Wallet ID (for wallet-level approvals) */ + wallet: t.string, + /** Enterprise ID (for enterprise-level approvals) */ + enterprise: t.string, + /** Number of approvals required */ + approvalsRequired: t.number, + /** Associated transaction request ID */ + txRequestId: t.string, + }), +]); + +/** + * Response for paying a lightning invoice + */ +export const LightningPaymentResponse = t.intersection([ + t.type({ + /** Payment request ID for tracking */ + txRequestId: t.string, + /** Status of the payment request ('delivered', 'pendingApproval', etc.) */ + txRequestState: TxRequestState, + }), + t.partial({ + /** Pending approval details (present when approval is required) */ + pendingApproval: PendingApproval, + /** Payment status on the Lightning Network (absent when pending approval) */ + paymentStatus: LndCreatePaymentResponse, + }), +]); + +/** + * Response status codes + */ +export const LightningPaymentResponseObj = { + /** Successfully submitted payment */ + 200: LightningPaymentResponse, + /** Invalid request */ + 400: BitgoExpressError, +} as const; + +/** + * Pay a Lightning Invoice + * + * Submits a payment for a BOLT #11 lightning invoice. The payment is signed with the user's + * authentication key and submitted to BitGo. If the payment requires additional approvals + * (based on wallet policy), returns pending approval details. Otherwise, the payment is + * immediately submitted to the Lightning Network. + * + * Fee limits can be controlled using either `feeLimitMsat` (absolute limit) or `feeLimitRatio` + * (as a ratio of payment amount). If both are provided, the more restrictive limit applies. + * + * For zero-amount invoices (invoices without a specified amount), the `amountMsat` field is required. + * + * @operationId express.v2.wallet.lightningPayment + * @tag express + */ +export const PostLightningWalletPayment = httpRoute({ + path: '/api/v2/{coin}/wallet/{id}/lightning/payment', + method: 'POST', + request: httpRequest({ + params: LightningPaymentParams, + body: LightningPaymentRequestBody, + }), + response: LightningPaymentResponseObj, +}); diff --git a/modules/express/test/unit/lightning/lightningInvoiceRoutes.test.ts b/modules/express/test/unit/lightning/lightningInvoiceRoutes.test.ts index 0a382a3364..0c9c06fc17 100644 --- a/modules/express/test/unit/lightning/lightningInvoiceRoutes.test.ts +++ b/modules/express/test/unit/lightning/lightningInvoiceRoutes.test.ts @@ -16,6 +16,12 @@ describe('Lightning Invoice Routes', () => { req.params = params.params || {}; req.query = params.query || {}; req.bitgo = params.bitgo; + // Add decoded property with both path params and body for typed routes + (req as any).decoded = { + coin: params.params?.coin, + id: params.params?.id, + ...params.body, + }; return req as express.Request; }; @@ -168,7 +174,9 @@ describe('Lightning Invoice Routes', () => { }); req.bitgo = bitgo; - await should(handlePayLightningInvoice(req)).be.rejectedWith('Invalid request body to pay lightning invoice'); + await should(handlePayLightningInvoice(req as any)).be.rejectedWith( + 'Invalid request body to pay lightning invoice' + ); }); it('should throw an error if the invoice is missing in the request params', async () => { @@ -183,7 +191,9 @@ describe('Lightning Invoice Routes', () => { }); req.bitgo = bitgo; - await should(handlePayLightningInvoice(req)).be.rejectedWith(/^Invalid request body to pay lightning invoice/); + await should(handlePayLightningInvoice(req as any)).be.rejectedWith( + /^Invalid request body to pay lightning invoice/ + ); }); }); }); diff --git a/modules/express/test/unit/typedRoutes/lightningPayment.ts b/modules/express/test/unit/typedRoutes/lightningPayment.ts new file mode 100644 index 0000000000..32f2079a2f --- /dev/null +++ b/modules/express/test/unit/typedRoutes/lightningPayment.ts @@ -0,0 +1,998 @@ +import * as assert from 'assert'; +import * as t from 'io-ts'; +import { + LightningPaymentParams, + LightningPaymentRequestBody, + LightningPaymentResponse, +} from '../../../src/typedRoutes/api/v2/lightningPayment'; +import { assertDecode } from './common'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import * as sinon from 'sinon'; +import { BitGo } from 'bitgo'; +import { setupAgent } from '../../lib/testutil'; + +describe('Lightning Payment API Tests', function () { + describe('Codec Validation Tests', function () { + describe('Path Parameters', function () { + it('should validate valid path parameters', function () { + const validParams = { + coin: 'tlnbtc', + id: '5d9f3d1c2e1a3b001a123456', + }; + + const decoded = assertDecode(t.type(LightningPaymentParams), validParams); + assert.strictEqual(decoded.coin, validParams.coin); + assert.strictEqual(decoded.id, validParams.id); + }); + + it('should validate mainnet coin', function () { + const validParams = { + coin: 'lnbtc', + id: '5d9f3d1c2e1a3b001a123456', + }; + + const decoded = assertDecode(t.type(LightningPaymentParams), validParams); + assert.strictEqual(decoded.coin, validParams.coin); + }); + + it('should fail validation without coin', function () { + const invalidParams = { + id: '5d9f3d1c2e1a3b001a123456', + }; + + const result = t.type(LightningPaymentParams).decode(invalidParams); + assert.strictEqual(result._tag, 'Left'); + }); + + it('should fail validation without wallet id', function () { + const invalidParams = { + coin: 'tlnbtc', + }; + + const result = t.type(LightningPaymentParams).decode(invalidParams); + assert.strictEqual(result._tag, 'Left'); + }); + }); + + describe('Request Body - Required Fields', function () { + it('should validate body with required fields only', function () { + const validBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + }; + + const decoded = assertDecode(t.type(LightningPaymentRequestBody), validBody); + assert.strictEqual(decoded.invoice, validBody.invoice); + assert.strictEqual(decoded.passphrase, validBody.passphrase); + }); + + it('should fail validation without invoice', function () { + const invalidBody = { + passphrase: 'myWalletPassphrase123', + }; + + const result = t.type(LightningPaymentRequestBody).decode(invalidBody); + assert.strictEqual(result._tag, 'Left'); + }); + + it('should fail validation without passphrase', function () { + const invalidBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + }; + + const result = t.type(LightningPaymentRequestBody).decode(invalidBody); + assert.strictEqual(result._tag, 'Left'); + }); + }); + + describe('Request Body - Optional Fields', function () { + it('should validate body with amountMsat', function () { + const validBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + amountMsat: '10000', + }; + + const decoded = assertDecode(t.type(LightningPaymentRequestBody), validBody); + assert.strictEqual(decoded.amountMsat, BigInt(10000)); + }); + + it('should validate body with feeLimitMsat', function () { + const validBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + feeLimitMsat: '1000', + }; + + const decoded = assertDecode(t.type(LightningPaymentRequestBody), validBody); + assert.strictEqual(decoded.feeLimitMsat, BigInt(1000)); + }); + + it('should validate body with feeLimitRatio', function () { + const validBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + feeLimitRatio: 0.01, + }; + + const decoded = assertDecode(t.type(LightningPaymentRequestBody), validBody); + assert.strictEqual(decoded.feeLimitRatio, validBody.feeLimitRatio); + }); + + it('should validate body with sequenceId', function () { + const validBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + sequenceId: 'payment-seq-123', + }; + + const decoded = assertDecode(t.type(LightningPaymentRequestBody), validBody); + assert.strictEqual(decoded.sequenceId, validBody.sequenceId); + }); + + it('should validate body with comment', function () { + const validBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + comment: 'Payment for services', + }; + + const decoded = assertDecode(t.type(LightningPaymentRequestBody), validBody); + assert.strictEqual(decoded.comment, validBody.comment); + }); + + it('should validate body with all optional fields', function () { + const validBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + amountMsat: '10000', + feeLimitMsat: '1000', + feeLimitRatio: 0.01, + sequenceId: 'payment-seq-123', + comment: 'Payment for services', + }; + + const decoded = assertDecode(t.type(LightningPaymentRequestBody), validBody); + assert.strictEqual(decoded.invoice, validBody.invoice); + assert.strictEqual(decoded.passphrase, validBody.passphrase); + assert.strictEqual(decoded.amountMsat, BigInt(10000)); + assert.strictEqual(decoded.feeLimitMsat, BigInt(1000)); + assert.strictEqual(decoded.feeLimitRatio, validBody.feeLimitRatio); + assert.strictEqual(decoded.sequenceId, validBody.sequenceId); + assert.strictEqual(decoded.comment, validBody.comment); + }); + }); + + describe('Response Validation', function () { + it('should validate successful payment response', function () { + const validResponse = { + txRequestId: 'txreq-abc123', + txRequestState: 'delivered', + paymentStatus: { + status: 'in_flight', + paymentHash: 'xyz789', + paymentId: 'payment-456', + }, + }; + + const decoded = assertDecode(LightningPaymentResponse, validResponse); + assert.strictEqual(decoded.txRequestId, validResponse.txRequestId); + assert.strictEqual(decoded.txRequestState, validResponse.txRequestState); + assert.strictEqual(decoded.paymentStatus?.status, validResponse.paymentStatus.status); + assert.strictEqual(decoded.paymentStatus?.paymentHash, validResponse.paymentStatus.paymentHash); + }); + + it('should validate settled payment response', function () { + const validResponse = { + txRequestId: 'txreq-abc123', + txRequestState: 'delivered', + paymentStatus: { + status: 'settled', + paymentHash: 'xyz789', + paymentPreimage: 'preimage123', + amountMsat: '10000', + feeMsat: '100', + }, + }; + + const decoded = assertDecode(LightningPaymentResponse, validResponse); + assert.strictEqual(decoded.paymentStatus?.status, 'settled'); + assert.strictEqual(decoded.paymentStatus?.paymentPreimage, validResponse.paymentStatus.paymentPreimage); + }); + + it('should validate failed payment response', function () { + const validResponse = { + txRequestId: 'txreq-abc123', + txRequestState: 'delivered', + paymentStatus: { + status: 'failed', + paymentHash: 'xyz789', + failureReason: 'NO_ROUTE', + }, + }; + + const decoded = assertDecode(LightningPaymentResponse, validResponse); + assert.strictEqual(decoded.paymentStatus?.status, 'failed'); + assert.strictEqual(decoded.paymentStatus?.failureReason, 'NO_ROUTE'); + }); + + it('should validate pending approval response', function () { + const validResponse = { + txRequestId: 'txreq-pending-123', + txRequestState: 'pendingApproval', + pendingApproval: { + id: 'approval-xyz', + state: 'pending', + creator: 'user-id-123', + info: { + type: 'transactionRequest', + }, + approvalsRequired: 2, + }, + }; + + const decoded = assertDecode(LightningPaymentResponse, validResponse); + assert.strictEqual(decoded.txRequestState, 'pendingApproval'); + assert.strictEqual(decoded.pendingApproval?.id, validResponse.pendingApproval.id); + assert.strictEqual(decoded.pendingApproval?.state, validResponse.pendingApproval.state); + assert.strictEqual(decoded.pendingApproval?.approvalsRequired, validResponse.pendingApproval.approvalsRequired); + }); + + it('should validate response with various txRequestStates', function () { + const states = [ + 'pendingCommitment', + 'pendingApproval', + 'canceled', + 'rejected', + 'initialized', + 'pendingDelivery', + 'delivered', + 'pendingUserSignature', + 'signed', + ]; + + states.forEach((state) => { + const validResponse = { + txRequestId: 'txreq-123', + txRequestState: state, + }; + + const decoded = assertDecode(LightningPaymentResponse, validResponse); + assert.strictEqual(decoded.txRequestState, state); + }); + }); + + it('should fail validation without txRequestId', function () { + const invalidResponse = { + txRequestState: 'delivered', + }; + + const result = LightningPaymentResponse.decode(invalidResponse); + assert.strictEqual(result._tag, 'Left'); + }); + + it('should fail validation without txRequestState', function () { + const invalidResponse = { + txRequestId: 'txreq-123', + }; + + const result = LightningPaymentResponse.decode(invalidResponse); + assert.strictEqual(result._tag, 'Left'); + }); + }); + }); + + describe('Integration Tests with Supertest', function () { + const agent = setupAgent(); + const walletId = '5d9f3d1c2e1a3b001a123456'; + const coin = 'tlnbtc'; + + afterEach(function () { + sinon.restore(); + }); + + // Helper function to create mock wallet with lightning support + function createMockLightningWallet(mockPaymentResponse: any) { + const mockTxRequestCreate = { + txRequestId: mockPaymentResponse.txRequestId, + state: 'initialized', + pendingApprovalId: undefined, + }; + + const mockTxRequestSend = { + txRequestId: mockPaymentResponse.txRequestId, + state: mockPaymentResponse.txRequestState, + transactions: [ + { + unsignedTx: { + coinSpecific: mockPaymentResponse.paymentStatus, + }, + }, + ], + }; + + const postStub = sinon.stub(); + postStub.onFirstCall().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(mockTxRequestCreate), + }), + }); + + postStub.onSecondCall().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves({}), + }), + }); + postStub.onThirdCall().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(mockTxRequestSend), + }), + }); + + const mockBitgo: any = { + setRequestTracer: sinon.stub(), + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + url: sinon.stub().returnsArg(0), + post: postStub, + get: sinon.stub().returns({ + result: sinon.stub().resolves({}), + }), + }; + + const mockKeychainsGet = sinon.stub(); + mockKeychainsGet.onCall(0).resolves({ + id: 'user-auth-key', + pub: 'user-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-user-key', + coinSpecific: { + tlnbtc: { + purpose: 'userAuth', + }, + }, + }); + mockKeychainsGet.onCall(1).resolves({ + id: 'node-auth-key', + pub: 'node-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-node-key', + coinSpecific: { + tlnbtc: { + purpose: 'nodeAuth', + }, + }, + }); + + const mockWallet: any = { + id: () => walletId, + baseCoin: { + getFamily: () => 'lnbtc', + getChain: () => 'tlnbtc', + keychains: () => ({ get: mockKeychainsGet }), + url: sinon.stub().returnsArg(0), + supportsTss: () => false, + }, + subType: () => 'lightningSelfCustody', + coinSpecific: () => ({ keys: ['user-auth-key', 'node-auth-key'] }), + bitgo: mockBitgo, + }; + + return { mockWallet, postStub, mockBitgo }; + } + + it('should successfully pay a lightning invoice - delivered status', async function () { + const requestBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + amountMsat: '10000', + sequenceId: 'payment-001', + }; + + const mockPaymentResponse = { + txRequestId: 'txreq-success-123', + txRequestState: 'delivered', + paymentStatus: { + status: 'in_flight', + paymentHash: 'abc123paymenthash', + paymentId: 'payment-id-456', + }, + }; + + // Create mock wallet with lightning support + const { mockWallet, postStub } = createMockLightningWallet(mockPaymentResponse); + + // Stub the coin and wallets + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { + wallets: () => mockWallets, + }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + // Make the request + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/lightning/payment`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + // Verify the response + assert.strictEqual(result.status, 200); + result.body.should.have.property('txRequestId'); + result.body.should.have.property('txRequestState'); + result.body.should.have.property('paymentStatus'); + assert.strictEqual(result.body.txRequestId, mockPaymentResponse.txRequestId); + assert.strictEqual(result.body.txRequestState, mockPaymentResponse.txRequestState); + assert.strictEqual(result.body.paymentStatus.status, 'in_flight'); + + // Validate response structure + const decodedResponse = assertDecode(LightningPaymentResponse, result.body); + assert.strictEqual(decodedResponse.txRequestId, mockPaymentResponse.txRequestId); + assert.strictEqual(decodedResponse.paymentStatus?.status, 'in_flight'); + + // Verify the correct methods were called + assert.strictEqual(walletsGetStub.calledOnceWith({ id: walletId }), true); + assert.strictEqual(postStub.calledThrice, true); + + // Verify the posts were called with correct endpoints + const firstPostCall = postStub.getCall(0); + assert.ok(firstPostCall.args[0].includes('/txrequests')); + }); + + it('should successfully pay a lightning invoice - pending approval', async function () { + const requestBody = { + invoice: 'lntb200u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + }; + + const mockTxRequest = { + txRequestId: 'txreq-pending-789', + state: 'pendingApproval', + pendingApprovalId: 'approval-xyz-123', + }; + + const mockPendingApproval = { + id: 'approval-xyz-123', + state: 'pending', + creator: 'user-abc-456', + info: { + type: 'transactionRequest', + }, + approvalsRequired: 2, + wallet: walletId, + }; + + // Mock keychains for auth keys + const mockKeychainsGet = sinon.stub(); + mockKeychainsGet.onCall(0).resolves({ + id: 'userAuthKeyId', + pub: 'user-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-user-key', + coinSpecific: { + tlnbtc: { + purpose: 'userAuth', + }, + }, + }); + mockKeychainsGet.onCall(1).resolves({ + id: 'nodeAuthKeyId', + pub: 'node-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-node-key', + coinSpecific: { + tlnbtc: { + purpose: 'nodeAuth', + }, + }, + }); + + // Mock the HTTP post for payment intent + const postStub = sinon.stub(); + postStub.onFirstCall().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(mockTxRequest), + }), + }); + + // Mock bitgo instance with required methods + const mockBitgo: any = { + setRequestTracer: sinon.stub(), + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + url: sinon.stub().returnsArg(0), + post: postStub, + get: sinon.stub().returns({ + result: sinon.stub().resolves({ + toJSON: () => mockPendingApproval, + }), + }), + }; + + const mockWallet: any = { + id: () => walletId, + baseCoin: { + getFamily: () => 'lnbtc', + getChain: () => 'tlnbtc', + keychains: () => ({ get: mockKeychainsGet }), + url: sinon.stub().returnsArg(0), + supportsTss: () => false, + }, + subType: () => 'lightningSelfCustody', + coinSpecific: () => ({ keys: ['userAuthKeyId', 'nodeAuthKeyId'] }), + bitgo: mockBitgo, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: () => mockWallets }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/lightning/payment`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + result.body.should.have.property('txRequestId'); + result.body.should.have.property('txRequestState'); + result.body.should.have.property('pendingApproval'); + assert.strictEqual(result.body.txRequestState, 'pendingApproval'); + assert.strictEqual(result.body.pendingApproval.id, mockPendingApproval.id); + assert.strictEqual(result.body.pendingApproval.approvalsRequired, 2); + + const decodedResponse = assertDecode(LightningPaymentResponse, result.body); + assert.strictEqual(decodedResponse.pendingApproval?.state, 'pending'); + }); + + it('should handle payment with fee limits', async function () { + const requestBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + feeLimitMsat: '1000', + feeLimitRatio: 0.01, + comment: 'Payment with fee controls', + }; + + const mockTxRequest = { + txRequestId: 'txreq-fee-controlled', + state: 'initialized', + }; + + const mockTxRequestSend = { + txRequestId: 'txreq-fee-controlled', + state: 'delivered', + transactions: [ + { + unsignedTx: { + coinSpecific: { + status: 'settled', + paymentHash: 'hash-with-fees', + paymentPreimage: 'preimage-success', + amountMsat: '10000', + feeMsat: '500', + }, + }, + }, + ], + }; + + // Mock keychains for auth keys + const mockKeychainsGet = sinon.stub(); + mockKeychainsGet.onCall(0).resolves({ + id: 'userAuthKeyId', + pub: 'user-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-user-key', + coinSpecific: { + tlnbtc: { + purpose: 'userAuth', + }, + }, + }); + mockKeychainsGet.onCall(1).resolves({ + id: 'nodeAuthKeyId', + pub: 'node-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-node-key', + coinSpecific: { + tlnbtc: { + purpose: 'nodeAuth', + }, + }, + }); + + // Mock the HTTP post for payment intent (non-pending approval flow) + const postStub = sinon.stub(); + postStub.onFirstCall().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(mockTxRequest), + }), + }); + // Second call for sending the transaction + postStub.onSecondCall().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves({}), + }), + }); + // Third call for getting tx request status + postStub.onThirdCall().returns({ + send: sinon.stub().returns({ + result: sinon.stub().resolves(mockTxRequestSend), + }), + }); + + // Mock bitgo instance with required methods + const mockBitgo: any = { + setRequestTracer: sinon.stub(), + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + url: sinon.stub().returnsArg(0), + post: postStub, + get: sinon.stub().returns({ + result: sinon.stub().resolves({}), + }), + }; + + const mockWallet: any = { + id: () => walletId, + baseCoin: { + getFamily: () => 'lnbtc', + getChain: () => 'tlnbtc', + keychains: () => ({ get: mockKeychainsGet }), + url: sinon.stub().returnsArg(0), + supportsTss: () => false, + }, + subType: () => 'lightningSelfCustody', + coinSpecific: () => ({ keys: ['userAuthKeyId', 'nodeAuthKeyId'] }), + bitgo: mockBitgo, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: () => mockWallets }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/lightning/payment`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.body.paymentStatus.status, 'settled'); + assert.strictEqual(result.body.paymentStatus.amountMsat, '10000'); + assert.strictEqual(result.body.paymentStatus.feeMsat, '500'); + }); + + describe('Error Cases', function () { + it('should return 400 when invoice is missing', async function () { + const requestBody = { + passphrase: 'myWalletPassphrase123', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/lightning/payment`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 400); + result.body.should.be.Array(); + result.body.length.should.be.above(0); + // Validation error should mention missing invoice field + result.body[0].should.match(/invoice/); + }); + + it('should return 400 when passphrase is missing', async function () { + const requestBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/lightning/payment`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 400); + result.body.should.be.Array(); + result.body.length.should.be.above(0); + // Validation error should mention missing passphrase field + result.body[0].should.match(/passphrase/); + }); + + it('should return 400 when amountMsat is invalid format', async function () { + const requestBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + amountMsat: 'not-a-number', + }; + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/lightning/payment`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 400); + result.body.should.be.Array(); + result.body.length.should.be.above(0); + // Validation error should mention amountMsat field + result.body[0].should.match(/amountMsat/); + }); + + it('should handle wallet not found error', async function () { + const requestBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + }; + + const walletsGetStub = sinon.stub().rejects(new Error('Wallet not found')); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: () => mockWallets }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/lightning/payment`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle invalid passphrase error', async function () { + const requestBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'wrong_passphrase', + }; + + const mockPayInvoiceStub = sinon.stub().rejects(new Error('Invalid passphrase')); + + // Mock keychains for auth keys + const mockKeychainsGet = sinon.stub(); + mockKeychainsGet.onCall(0).resolves({ + id: 'userAuthKeyId', + pub: 'user-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-user-key', + coinSpecific: { + tlnbtc: { + purpose: 'userAuth', + }, + }, + }); + mockKeychainsGet.onCall(1).resolves({ + id: 'nodeAuthKeyId', + pub: 'node-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-node-key', + coinSpecific: { + tlnbtc: { + purpose: 'nodeAuth', + }, + }, + }); + + // Mock bitgo instance with required methods + const mockBitgo: any = { + setRequestTracer: sinon.stub(), + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + url: sinon.stub().returnsArg(0), + post: sinon.stub().returns({ + send: sinon.stub().returns({ + result: sinon.stub().rejects(mockPayInvoiceStub), + }), + }), + get: sinon.stub().returns({ + result: sinon.stub().resolves({}), + }), + }; + + const mockWallet: any = { + id: () => walletId, + baseCoin: { + getFamily: () => 'lnbtc', + getChain: () => 'tlnbtc', + keychains: () => ({ get: mockKeychainsGet }), + }, + subType: () => 'lightningSelfCustody', + coinSpecific: () => ({ keys: ['userAuthKeyId', 'nodeAuthKeyId'] }), + bitgo: mockBitgo, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: () => mockWallets }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/lightning/payment`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle payment timeout error', async function () { + const requestBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + }; + + const mockPayInvoiceStub = sinon.stub().rejects(new Error('Payment timeout')); + + // Mock keychains for auth keys + const mockKeychainsGet = sinon.stub(); + mockKeychainsGet.onCall(0).resolves({ + id: 'userAuthKeyId', + pub: 'user-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-user-key', + coinSpecific: { + tlnbtc: { + purpose: 'userAuth', + }, + }, + }); + mockKeychainsGet.onCall(1).resolves({ + id: 'nodeAuthKeyId', + pub: 'node-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-node-key', + coinSpecific: { + tlnbtc: { + purpose: 'nodeAuth', + }, + }, + }); + + // Mock bitgo instance with required methods + const mockBitgo: any = { + setRequestTracer: sinon.stub(), + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + url: sinon.stub().returnsArg(0), + post: sinon.stub().returns({ + send: sinon.stub().returns({ + result: sinon.stub().rejects(mockPayInvoiceStub), + }), + }), + get: sinon.stub().returns({ + result: sinon.stub().resolves({}), + }), + }; + + const mockWallet: any = { + id: () => walletId, + baseCoin: { + getFamily: () => 'lnbtc', + getChain: () => 'tlnbtc', + keychains: () => ({ get: mockKeychainsGet }), + }, + subType: () => 'lightningSelfCustody', + coinSpecific: () => ({ keys: ['userAuthKeyId', 'nodeAuthKeyId'] }), + bitgo: mockBitgo, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: () => mockWallets }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/lightning/payment`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + + it('should handle insufficient balance error', async function () { + const requestBody = { + invoice: 'lntb100u1p3h2jk3pp5yndyvx4zmvxyz', + passphrase: 'myWalletPassphrase123', + amountMsat: '1000000000', + }; + + const mockPayInvoiceStub = sinon.stub().rejects(new Error('Insufficient balance')); + + // Mock keychains for auth keys + const mockKeychainsGet = sinon.stub(); + mockKeychainsGet.onCall(0).resolves({ + id: 'userAuthKeyId', + pub: 'user-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-user-key', + coinSpecific: { + tlnbtc: { + purpose: 'userAuth', + }, + }, + }); + mockKeychainsGet.onCall(1).resolves({ + id: 'nodeAuthKeyId', + pub: 'node-auth-public-key', + source: 'user', + encryptedPrv: 'encrypted-node-key', + coinSpecific: { + tlnbtc: { + purpose: 'nodeAuth', + }, + }, + }); + + // Mock bitgo instance with required methods + const mockBitgo: any = { + setRequestTracer: sinon.stub(), + decrypt: sinon + .stub() + .returns( + 'xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqjiChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi' + ), + url: sinon.stub().returnsArg(0), + post: sinon.stub().returns({ + send: sinon.stub().returns({ + result: sinon.stub().rejects(mockPayInvoiceStub), + }), + }), + get: sinon.stub().returns({ + result: sinon.stub().resolves({}), + }), + }; + + const mockWallet: any = { + id: () => walletId, + baseCoin: { + getFamily: () => 'lnbtc', + getChain: () => 'tlnbtc', + keychains: () => ({ get: mockKeychainsGet }), + }, + subType: () => 'lightningSelfCustody', + coinSpecific: () => ({ keys: ['userAuthKeyId', 'nodeAuthKeyId'] }), + bitgo: mockBitgo, + }; + + const walletsGetStub = sinon.stub().resolves(mockWallet); + const mockWallets = { get: walletsGetStub }; + const mockCoin = { wallets: () => mockWallets }; + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + + const result = await agent + .post(`/api/v2/${coin}/wallet/${walletId}/lightning/payment`) + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 500); + result.body.should.have.property('error'); + }); + }); + }); +});