Skip to content

Commit

Permalink
feat: add support to create lightning payments
Browse files Browse the repository at this point in the history
Ticket: BTC-1776
  • Loading branch information
lcovar committed Feb 25, 2025
1 parent a94bd33 commit bb8909c
Show file tree
Hide file tree
Showing 6 changed files with 275 additions and 32 deletions.
1 change: 1 addition & 0 deletions modules/abstract-lightning/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
]
},
"dependencies": {
"@bitgo/public-types": "4.17.0",
"@bitgo/sdk-core": "^29.0.0",
"@bitgo/statics": "^51.0.1",
"@bitgo/utxo-lib": "^11.2.2",
Expand Down
29 changes: 29 additions & 0 deletions modules/abstract-lightning/src/codecs/api/payment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as t from 'io-ts';
import { BigIntFromString } from 'io-ts-types/BigIntFromString';
import { DateFromISOString } from 'io-ts-types/DateFromISOString';
import { LightningPaymentRequest, optionalString } from '@bitgo/public-types';

// codecs for lightning wallet payment related apis

Expand Down Expand Up @@ -84,3 +85,31 @@ export const PaymentQuery = t.partial(
'PaymentQuery'
);
export type PaymentQuery = t.TypeOf<typeof PaymentQuery>;

export const SubmitPaymentParams = t.intersection([
LightningPaymentRequest,
t.type({
sequenceId: optionalString,
comment: optionalString,
}),
]);

export type SubmitPaymentParams = t.TypeOf<typeof SubmitPaymentParams>;

export const LndCreatePaymentResponse = t.intersection(
[
t.strict({
status: PaymentStatus,
paymentHash: t.string,
}),
t.partial({
paymentPreimage: t.string,
amountMsat: BigIntFromString,
feeMsat: BigIntFromString,
failureReason: PaymentFailureReason,
}),
],
'LndCreatePaymentResponse'
);

export type LndCreatePaymentResponse = t.TypeOf<typeof LndCreatePaymentResponse>;
80 changes: 77 additions & 3 deletions modules/abstract-lightning/src/wallet/lightning.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import * as sdkcore from '@bitgo/sdk-core';
import {
PendingApprovalData,
PendingApprovals,
RequestTracer,
RequestType,
TxRequest,
commonTssMethods,
TxRequestState,
} from '@bitgo/sdk-core';
import * as t from 'io-ts';
import { createMessageSignature, unwrapLightningCoinSpecific } from '../lightning';
import {
Expand All @@ -8,8 +17,19 @@ import {
InvoiceQuery,
LightningAuthKeychain,
LightningKeychain,
LndCreatePaymentResponse,
SubmitPaymentParams,
UpdateLightningWalletSignedRequest,
} from '../codecs';
import { LightningPaymentIntent, LightningPaymentRequest } from '@bitgo/public-types';

export type PayInvoiceResponse = {
txRequestId: string;
txRequestState: TxRequestState;
pendingApproval?: PendingApprovalData;
// Absent if there's a pending approval
paymentStatus?: LndCreatePaymentResponse;
};

export interface ILightningWallet {
/**
Expand Down Expand Up @@ -38,8 +58,9 @@ export interface ILightningWallet {
/**
* Pay a lightning invoice
* @param params Payment parameters (to be defined)
* @param passphrase wallet passphrase to decrypt the user auth key
*/
payInvoice(params: unknown): Promise<unknown>;
payInvoice(params: unknown, passphrase: string): Promise<PayInvoiceResponse>;

/**
* Get the lightning keychain for the given wallet.
Expand Down Expand Up @@ -91,8 +112,61 @@ export class SelfCustodialLightningWallet implements ILightningWallet {
});
}

async payInvoice(params: unknown): Promise<unknown> {
throw new Error('Method not implemented.');
async payInvoice(params: SubmitPaymentParams, passphrase: string): Promise<PayInvoiceResponse> {
const reqId = new RequestTracer();
this.wallet.bitgo.setRequestTracer(reqId);

const { userAuthKey } = await this.getLightningAuthKeychains();
const signature = createMessageSignature(
t.exact(LightningPaymentRequest).encode(params),
this.wallet.bitgo.decrypt({ password: passphrase, input: userAuthKey.encryptedPrv })
);

const paymentIntent: LightningPaymentIntent = {
comment: params.comment,
sequenceId: params.sequenceId,
intentType: 'payment',
signedRequest: {
invoice: params.invoice,
amountMsat: params.amountMsat,
feeLimitMsat: params.feeLimitMsat,
feeLimitRatio: params.feeLimitRatio,
},
signature,
};

const transactionRequestCreate = (await this.wallet.bitgo
.post(this.wallet.bitgo.url('/wallet/' + this.wallet.id() + '/txrequests', 2))
.send(LightningPaymentIntent.encode(paymentIntent))
.result()) as TxRequest;

if (transactionRequestCreate.state === 'pendingApproval') {
const pendingApprovals = new PendingApprovals(this.wallet.bitgo, this.wallet.baseCoin);
const pendingApproval = await pendingApprovals.get({ id: transactionRequestCreate.pendingApprovalId });
return {
pendingApproval: pendingApproval.toJSON(),
txRequestId: transactionRequestCreate.txRequestId,
txRequestState: transactionRequestCreate.state,
};
}

const transactionRequestSend = await commonTssMethods.sendTxRequest(
this.wallet.bitgo,
this.wallet.id(),
transactionRequestCreate.txRequestId,
RequestType.tx,
reqId
);

const coinSpecific = transactionRequestSend.transactions?.[0]?.unsignedTx?.coinSpecific;

return {
txRequestId: transactionRequestCreate.txRequestId,
txRequestState: transactionRequestSend.state,
paymentStatus: coinSpecific
? LndCreatePaymentResponse.encode(coinSpecific as LndCreatePaymentResponse)
: undefined,
};
}

async listInvoices(params: InvoiceQuery): Promise<InvoiceInfo[]> {
Expand Down
195 changes: 167 additions & 28 deletions modules/bitgo/test/v2/unit/lightning/lightningWallets.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck

import * as assert from 'assert';
import { TestBitGo } from '@bitgo/sdk-test';
import * as nock from 'nock';
import { BaseCoin } from '@bitgo/sdk-core';
import { BaseCoin, PendingApprovalData, State, Type } from '@bitgo/sdk-core';
import {
CreateInvoiceBody,
getLightningWallet,
Invoice,
InvoiceInfo,
InvoiceQuery,
LndCreatePaymentResponse,
SelfCustodialLightningWallet,
SubmitPaymentParams,
} from '@bitgo/abstract-lightning';

import { BitGo, common, GenerateLightningWalletOptions, Wallet, Wallets } from '../../../../src';
Expand All @@ -20,6 +25,32 @@ describe('Lightning wallets', function () {
let wallets: Wallets;
let bgUrl: string;

const userAuthKey = {
id: 'def',
pub: 'xpub661MyMwAqRbcGYjYsnsDj1SHdiXynWEXNnfNgMSpokN54FKyMqbu7rWEfVNDs6uAJmz86UVFtq4sefhQpXZhSAzQcL9zrEPtiLNNZoeSxCG',
encryptedPrv:
'{"iv":"zYhhaNdW0wPfJEoBjZ4pvg==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"tgAMua9jjhw=","ct":"HcrbxQvNlWG5tLMndYzdNCYa1l+1h7o+vSsweA0+q1le3tWt6jLUJSEjZN+JI8lTZ2KPFQgLulQQhsUa+ytUCBi0vSgjF7x7CprT7l2Cfjkew00XsEd7wnmtJUsrQk8m69Co7tIRA3oEgzrnYwy4qOM81lbNNyQ="}',
source: 'user',
coinSpecific: {
tlnbtc: {
purpose: 'userAuth',
},
},
};

const nodeAuthKey = {
id: 'ghi',
pub: 'xpub661MyMwAqRbcG9xnTnAnRbJPo3MAHyRtH4zeehN8exYk4VFz5buepUzebhix33BKhS5Eb4V3LEfW5pYiSR8qmaEnyrpeghhKY8JfzAsUDpq',
encryptedPrv:
'{"iv":"bH6eGbnl9x8PZECPrgvcng==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"o8yknV6nTI8=","ct":"nGyzAToIzYkQeIdcVafoWHtMx7+Fgj0YldCme3WA1yxJAA0QulZVhblMZN/7efCRIumA0NNmpH7dxH6n8cVlz/Z+RUgC2q9lgvZKUoJcYNTjWUfkmkJutXX2tr8yVxm+eC/hnRiyfVLZ2qPxctvDlBVBfgLuPyc="}',
source: 'user',
coinSpecific: {
tlnbtc: {
purpose: 'nodeAuth',
},
},
};

before(function () {
bitgo.initializeTestVars();

Expand Down Expand Up @@ -191,7 +222,7 @@ describe('Lightning wallets', function () {
let wallet: SelfCustodialLightningWallet;
beforeEach(function () {
wallet = getLightningWallet(
new Wallet(bitgo, basecoin, { id: 'walletId', coin: 'tlnbtc' })
new Wallet(bitgo, basecoin, { id: 'walletId', coin: 'tlnbtc', coinSpecific: { keys: ['def', 'ghi'] } })
) as SelfCustodialLightningWallet;
});

Expand Down Expand Up @@ -262,6 +293,140 @@ describe('Lightning wallets', function () {
await assert.rejects(async () => await wallet.createInvoice(createInvoice), /Invalid create invoice response/);
createInvoiceNock.done();
});

it('should pay invoice', async function () {
const params: SubmitPaymentParams = {
invoice: 'lnbc1...',
amountMsat: 1000n,
feeLimitMsat: 100n,
feeLimitRatio: 0.1,
sequenceId: '123',
comment: 'test payment',
};

const txRequestResponse = {
txRequestId: 'txReq123',
state: 'delivered',
};

const lndResponse: LndCreatePaymentResponse = {
status: 'settled',
paymentHash: 'paymentHash123',
amountMsat: params.amountMsat.toString(),
feeMsat: params.feeLimitMsat.toString(),
paymentPreimage: 'preimage123',
};

const finalPaymentResponse = {
txRequestId: 'txReq123',
state: 'delivered',
transactions: [
{
unsignedTx: {
coinSpecific: {
...lndResponse,
},
},
},
],
};

const createTxRequestNock = nock(bgUrl)
.post(`/api/v2/wallet/${wallet.wallet.id()}/txrequests`)
.reply(200, txRequestResponse);

const sendTxRequestNock = nock(bgUrl)
.post(`/api/v2/wallet/${wallet.wallet.id()}/txrequests/${txRequestResponse.txRequestId}/transactions/0/send`)
.reply(200, finalPaymentResponse);

const userAuthKeyNock = nock(bgUrl)
.get('/api/v2/' + coinName + '/key/def')
.reply(200, userAuthKey);
const nodeAuthKeyNock = nock(bgUrl)
.get('/api/v2/' + coinName + '/key/ghi')
.reply(200, nodeAuthKey);

const response = await wallet.payInvoice(params, 'password123');
assert.strictEqual(response.txRequestId, 'txReq123');
assert.strictEqual(response.txRequestState, 'delivered');
assert.strictEqual(
response.paymentStatus.status,
finalPaymentResponse.transactions[0].unsignedTx.coinSpecific.status
);
assert.strictEqual(
response.paymentStatus.paymentHash,
finalPaymentResponse.transactions[0].unsignedTx.coinSpecific.paymentHash
);
assert.strictEqual(
response.paymentStatus.amountMsat,
finalPaymentResponse.transactions[0].unsignedTx.coinSpecific.amountMsat
);
assert.strictEqual(
response.paymentStatus.feeMsat,
finalPaymentResponse.transactions[0].unsignedTx.coinSpecific.feeMsat
);
assert.strictEqual(
response.paymentStatus.paymentPreimage,
finalPaymentResponse.transactions[0].unsignedTx.coinSpecific.paymentPreimage
);

createTxRequestNock.done();
sendTxRequestNock.done();
userAuthKeyNock.done();
nodeAuthKeyNock.done();
});

it('should handle pending approval when paying invoice', async function () {
const params: SubmitPaymentParams = {
invoice: 'lnbc1...',
amountMsat: 1000n,
feeLimitMsat: 100n,
feeLimitRatio: 0.1,
sequenceId: '123',
comment: 'test payment',
};

const txRequestResponse = {
txRequestId: 'txReq123',
state: 'pendingApproval',
pendingApprovalId: 'approval123',
};

const pendingApprovalData: PendingApprovalData = {
id: 'approval123',
state: State.PENDING,
creator: 'user123',
info: {
type: Type.TRANSACTION_REQUEST,
},
};

const createTxRequestNock = nock(bgUrl)
.post(`/api/v2/wallet/${wallet.wallet.id()}/txrequests`)
.reply(200, txRequestResponse);

const getPendingApprovalNock = nock(bgUrl)
.get(`/api/v2/${coinName}/pendingapprovals/${txRequestResponse.pendingApprovalId}`)
.reply(200, pendingApprovalData);

const userAuthKeyNock = nock(bgUrl)
.get('/api/v2/' + coinName + '/key/def')
.reply(200, userAuthKey);
const nodeAuthKeyNock = nock(bgUrl)
.get('/api/v2/' + coinName + '/key/ghi')
.reply(200, nodeAuthKey);

const response = await wallet.payInvoice(params, 'password123');
assert.strictEqual(response.txRequestId, 'txReq123');
assert.strictEqual(response.txRequestState, 'pendingApproval');
assert(response.pendingApproval);
assert.strictEqual(response.status, undefined);

createTxRequestNock.done();
getPendingApprovalNock.done();
userAuthKeyNock.done();
nodeAuthKeyNock.done();
});
});

describe('Get lightning key(s)', function () {
Expand Down Expand Up @@ -386,32 +551,6 @@ describe('Lightning wallets', function () {
coinSpecific: { keys: ['def', 'ghi'] },
};

const userAuthKey = {
id: 'def',
pub: 'xpub661MyMwAqRbcGYjYsnsDj1SHdiXynWEXNnfNgMSpokN54FKyMqbu7rWEfVNDs6uAJmz86UVFtq4sefhQpXZhSAzQcL9zrEPtiLNNZoeSxCG',
encryptedPrv:
'{"iv":"zYhhaNdW0wPfJEoBjZ4pvg==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"tgAMua9jjhw=","ct":"HcrbxQvNlWG5tLMndYzdNCYa1l+1h7o+vSsweA0+q1le3tWt6jLUJSEjZN+JI8lTZ2KPFQgLulQQhsUa+ytUCBi0vSgjF7x7CprT7l2Cfjkew00XsEd7wnmtJUsrQk8m69Co7tIRA3oEgzrnYwy4qOM81lbNNyQ="}',
source: 'user',
coinSpecific: {
tlnbtc: {
purpose: 'userAuth',
},
},
};

const nodeAuthKey = {
id: 'ghi',
pub: 'xpub661MyMwAqRbcG9xnTnAnRbJPo3MAHyRtH4zeehN8exYk4VFz5buepUzebhix33BKhS5Eb4V3LEfW5pYiSR8qmaEnyrpeghhKY8JfzAsUDpq',
encryptedPrv:
'{"iv":"bH6eGbnl9x8PZECPrgvcng==","v":1,"iter":10000,"ks":256,"ts":64,"mode":"ccm","adata":"","cipher":"aes","salt":"o8yknV6nTI8=","ct":"nGyzAToIzYkQeIdcVafoWHtMx7+Fgj0YldCme3WA1yxJAA0QulZVhblMZN/7efCRIumA0NNmpH7dxH6n8cVlz/Z+RUgC2q9lgvZKUoJcYNTjWUfkmkJutXX2tr8yVxm+eC/hnRiyfVLZ2qPxctvDlBVBfgLuPyc="}',
source: 'user',
coinSpecific: {
tlnbtc: {
purpose: 'nodeAuth',
},
},
};

const watchOnlyAccounts = {
master_key_birthday_timestamp: 'dummy',
master_key_fingerprint: 'dummy',
Expand Down
Loading

0 comments on commit bb8909c

Please sign in to comment.