ln-service
Version:
Interaction helper for your Lightning Network daemon
480 lines (394 loc) • 17.6 kB
JavaScript
const {ok} = require('node:assert').strict;
const {strictEqual} = require('node:assert').strict;
const test = require('node:test');
const asyncRetry = require('async/retry');
const {createPsbt} = require('psbt');
const {combinePsbts} = require('psbt');
const {decodePsbt} = require('psbt');
const {extractTransaction} = require('psbt');
const {finalizePsbt} = require('psbt');
const {networks} = require('bitcoinjs-lib');
const {payments} = require('bitcoinjs-lib');
const {script} = require('bitcoinjs-lib');
const {spawnLightningCluster} = require('ln-docker-daemons');
const tinysecp = require('tiny-secp256k1');
const {Transaction} = require('bitcoinjs-lib');
const {updatePsbt} = require('psbt');
const {addPeer} = require('./../../');
const {broadcastChainTransaction} = require('./../../');
const {createChainAddress} = require('./../../');
const {fundPsbt} = require('./../../');
const {getChainBalance} = require('./../../');
const {getChannels} = require('./../../');
const {getPendingChannels} = require('./../../');
const {getPublicKey} = require('./../../');
const {getWalletInfo} = require('./../../');
const {prepareForChannelProposal} = require('./../../');
const {proposeChannel} = require('./../../');
const {signPsbt} = require('./../../');
const {signTransaction} = require('./../../');
const capacity = 1e6;
const {ceil} = Math;
const cooperativeCloseDelay = 2016;
const family = 0;
const feeRate = 1;
const {fromHex} = Transaction;
const fundingFee = 190; // Vsize of 2 input, 1 output tx
const interval = 100;
const keyIndex = 0;
const network = 'regtest';
const {p2ms} = payments;
const {p2pkh} = payments;
const {regtest} = networks;
const reserveRatio = 0.01;
const size = 2;
const temporaryFamily = 805;
const times = 300;
// Proposing a cooperative delay channel should open a cooperative delay chan
test(`Propose a channel with a coop delay`, async () => {
const ecp = (await import('ecpair')).ECPairFactory(tinysecp);
const {kill, nodes} = await spawnLightningCluster({size});
const [control, target] = nodes;
const {lnd, generate} = control;
try {
// Generate some funds for LND
await asyncRetry({times}, async () => {
await generate({});
await addPeer({lnd, public_key: target.id, socket: target.socket});
await generate({});
const wallet = await getChainBalance({lnd});
if (!wallet.chain_balance) {
throw new Error('ExpectedChainBalanceForNode');
}
});
// Generate some funds for LND
await asyncRetry({times}, async () => {
await target.generate({});
const wallet = await getChainBalance({lnd: target.lnd});
if (!wallet.chain_balance) {
throw new Error('ExpectedChainBalanceForNode');
}
});
const {features} = await getWalletInfo({lnd});
// Derive a temporary key for control to pay into
const controlDerivedKey = await getPublicKey({
lnd,
family: temporaryFamily,
});
// Derive a temporary key for target to pay into
const targetDerivedKey = await getPublicKey({
family: temporaryFamily,
lnd: target.lnd,
});
// Control should fund and sign a transaction going to the control temp key
const controlDerivedAddress = payments.p2wpkh({
network: regtest,
pubkey: Buffer.from(controlDerivedKey.public_key, 'hex'),
});
// Target should fund and sign a transaction going to the target temp key
const targetDerivedAddress = payments.p2wpkh({
network: regtest,
pubkey: Buffer.from(targetDerivedKey.public_key, 'hex'),
});
const temporaryKeys = [controlDerivedKey, targetDerivedKey];
const giveTokens = ceil(capacity / temporaryKeys.length);
// Control can now fund a transaction to pay to the temp address
const controlFundPsbt = await fundPsbt({
lnd,
fee_tokens_per_vbyte: feeRate,
outputs: [{
address: controlDerivedAddress.address,
tokens: giveTokens + ceil(fundingFee / temporaryKeys.length),
}],
});
// Target can now fund a transaction to pay to the temp address
const targetFundPsbt = await fundPsbt({
lnd: target.lnd,
fee_tokens_per_vbyte: feeRate,
outputs: [{
address: targetDerivedAddress.address,
tokens: giveTokens + ceil(fundingFee / temporaryKeys.length),
}],
});
// Control can sign the funding to the temporary address
const controlSignPsbt = await signPsbt({lnd, psbt: controlFundPsbt.psbt});
// Target can sign the funding to the temporary address
const targetSignPsbt = await signPsbt({
lnd: target.lnd,
psbt: targetFundPsbt.psbt,
});
// Decode the control funded PSBT
const controlPsbt = decodePsbt({ecp, psbt: controlFundPsbt.psbt});
// Decode the target funded PSBT
const targetPsbt = decodePsbt({ecp, psbt: targetFundPsbt.psbt});
// Derive the id of the control pre-funding tx
const controlId = fromHex(controlPsbt.unsigned_transaction).getId();
// Derive the id of the target pre-funding tx
const targetId = fromHex(targetPsbt.unsigned_transaction).getId();
// Derive a new control key for a 2:2 multisig
const controlMultiSigKey = await getPublicKey({family, lnd});
// Derive a new target key for a 2:2 multisig
const targetMultiSigKey = await getPublicKey({family, lnd: target.lnd});
const fundingMultiSigKeys = [
controlMultiSigKey.public_key,
targetMultiSigKey.public_key,
];
// Make the channel 2:2 funding output from control and target keys
const dualFundingChannelAddress = payments.p2wsh({
redeem: p2ms({
m: fundingMultiSigKeys.length,
pubkeys: fundingMultiSigKeys.sort().map(n => Buffer.from(n, 'hex')),
}),
});
const pendingChannelId = dualFundingChannelAddress.hash;
// Create the basic PSBT that spends temporary funds to the 2:2 funding
const dualFundPsbt = createPsbt({
outputs: [{
script: dualFundingChannelAddress.output.toString('hex'),
tokens: capacity,
}],
utxos: [
{
id: fromHex(controlSignPsbt.transaction).getId(),
vout: controlFundPsbt.outputs.findIndex(n => !n.is_change),
},
{
id: fromHex(targetSignPsbt.transaction).getId(),
vout: targetFundPsbt.outputs.findIndex(n => !n.is_change),
},
],
});
const controlWithoutWitnessTx = fromHex(controlSignPsbt.transaction);
const targetWithoutWitnessTx = fromHex(targetSignPsbt.transaction);
// Eliminate the witnesses
controlWithoutWitnessTx.ins.forEach((input, index) => {
return controlWithoutWitnessTx.setWitness(index, []);
});
targetWithoutWitnessTx.ins.forEach((input, index) => {
return targetWithoutWitnessTx.setWitness(index, []);
});
// Add the spending transactions to the psbt
const psbtWithSpending = updatePsbt({
ecp,
psbt: dualFundPsbt.psbt,
transactions: [
controlWithoutWitnessTx.toHex(),
targetWithoutWitnessTx.toHex(),
],
});
const finalFundingPsbt = decodePsbt({ecp, psbt: dualFundPsbt.psbt});
const fundingTx = fromHex(finalFundingPsbt.unsigned_transaction);
const fundingTxId = fundingTx.getId();
const fundingTxVout = fundingTx.outs.findIndex(n => n.value === capacity);
const controlTxHash = controlWithoutWitnessTx.getHash();
const targetTxHash = targetWithoutWitnessTx.getHash();
const controlVin = fundingTx.ins.findIndex(({hash}) => {
return hash.equals(controlTxHash);
});
const targetVin = fundingTx.ins.findIndex(({hash}) => {
return hash.equals(targetTxHash);
});
const decodePayout = decodePsbt({ecp, psbt: psbtWithSpending.psbt});
// Call signTransaction on the unsigned tx that pays from temp -> multisig
const controlSignDerivedKey = await signTransaction({
lnd,
inputs: [{
key_family: temporaryFamily,
key_index: controlDerivedKey.index,
output_script: dualFundingChannelAddress.output.toString('hex'),
output_tokens: giveTokens + ceil(fundingFee / temporaryKeys.length),
sighash: Transaction.SIGHASH_ALL,
vin: controlVin,
witness_script: p2pkh({hash: controlDerivedAddress.hash}).output,
}],
transaction: decodePayout.unsigned_transaction,
});
const [controlDerivedSignature] = controlSignDerivedKey.signatures;
const controlSignSpendingPsbt = updatePsbt({
ecp,
psbt: psbtWithSpending.psbt,
signatures: controlSignDerivedKey.signatures.map(sig => {
return {
signature: Buffer.concat([
Buffer.from(sig, 'hex'),
Buffer.from([Transaction.SIGHASH_ALL]),
]).toString('hex') ,
hash_type: Transaction.SIGHASH_ALL,
public_key: controlDerivedKey.public_key,
vin: controlVin,
};
}),
});
// Call signTransaction on the unsigned tx that pays from temp -> multisig
const targetSignDerivedKey = await signTransaction({
inputs: [{
key_family: temporaryFamily,
key_index: targetDerivedKey.index,
output_script: dualFundingChannelAddress.output.toString('hex'),
output_tokens: giveTokens + ceil(fundingFee / temporaryKeys.length),
sighash: Transaction.SIGHASH_ALL,
vin: targetVin,
witness_script: p2pkh({hash: targetDerivedAddress.hash}).output,
}],
lnd: target.lnd,
transaction: decodePsbt({
ecp,
psbt: psbtWithSpending.psbt,
}).unsigned_transaction,
});
const [targetDerivedSignature] = targetSignDerivedKey.signatures;
const targetSignSpendingPsbt = updatePsbt({
ecp,
psbt: psbtWithSpending.psbt,
signatures: targetSignDerivedKey.signatures.map(sig => {
return {
signature: Buffer.concat([
Buffer.from(sig, 'hex'),
Buffer.from([Transaction.SIGHASH_ALL]),
]).toString('hex') ,
hash_type: Transaction.SIGHASH_ALL,
public_key: targetDerivedKey.public_key,
vin: targetVin,
};
}),
});
// Use the anticipated funding tx to prepare for a new channel open
await prepareForChannelProposal({
cooperative_close_delay: cooperativeCloseDelay,
id: pendingChannelId.toString('hex'),
key_index: targetMultiSigKey.index,
lnd: target.lnd,
remote_key: controlMultiSigKey.public_key,
transaction_id: fundingTxId,
transaction_vout: fundingTxVout,
});
const coopCloseAddress = await createChainAddress({
format: 'p2wpkh',
lnd: control.lnd,
});
// Propose the channel to the target
await proposeChannel({
capacity,
cooperative_close_address: coopCloseAddress.address,
cooperative_close_delay: cooperativeCloseDelay,
give_tokens: capacity / fundingMultiSigKeys.length,
id: pendingChannelId.toString('hex'),
is_private: true,
key_index: controlMultiSigKey.index,
lnd: control.lnd,
partner_public_key: target.id,
remote_key: targetMultiSigKey.public_key,
transaction_id: fundingTxId,
transaction_vout: fundingTxVout,
});
const pendingTarget = await getPendingChannels({lnd: target.lnd});
const [incoming] = pendingTarget.pending_channels;
strictEqual(incoming.remote_balance, 496530, 'Remote balance amount');
strictEqual(incoming.transaction_fee, 2810, 'Commit tx fee');
strictEqual(incoming.transaction_weight, 1116, 'Funding tx weight');
strictEqual(incoming.capacity, 1000000, 'Incoming capacity is defined');
strictEqual(incoming.close_transaction_id, undefined, 'Not a closing tx');
strictEqual(incoming.is_active, false, 'Not active yet');
strictEqual(incoming.is_closing, false, 'Channel is not closing');
strictEqual(incoming.is_opening, true, 'Channel is opening');
strictEqual(incoming.is_partner_initiated, true, 'Peer initiated channel');
strictEqual(incoming.local_balance, giveTokens, 'The incoming is split');
strictEqual(incoming.local_reserve, capacity * reserveRatio, 'Reserve');
strictEqual(incoming.partner_public_key, control.id, 'Peer key');
strictEqual(incoming.pending_balance, undefined, 'No tokens pending');
strictEqual(incoming.pending_payments, undefined, 'No HTLCs active');
strictEqual(incoming.received, 0, 'Nothing received');
strictEqual(incoming.recovered_tokens, undefined, 'No recovery');
strictEqual(incoming.remote_reserve, capacity * reserveRatio, 'Reserve');
strictEqual(incoming.sent, 0, 'Nothing sent');
strictEqual(incoming.timelock_expiration, undefined, 'No timelock');
strictEqual(incoming.transaction_id, fundingTxId, 'Funding tx id correct');
strictEqual(incoming.transaction_vout, fundingTxVout, 'Funding vout');
// Setup the combined signed PSBTs that fund the channel
const combinedTempPsbt = combinePsbts({
ecp,
psbts: [
controlSignSpendingPsbt,
targetSignSpendingPsbt,
].map(n => n.psbt),
});
// Finalize the combined PSBT
const finalTempPsbt = finalizePsbt({ecp, psbt: combinedTempPsbt.psbt});
// Pull out the signed broadcast-ready transaction from the PSBT
const finalTempTx = extractTransaction({ecp, psbt: finalTempPsbt.psbt});
// Calculate the size of the tx
const txSize = fromHex(finalTempTx.transaction).virtualSize();
strictEqual(txSize <= fundingFee, true, 'Transaction size is not large');
// Broadcast the transaction to fund the control side
await broadcastChainTransaction({
lnd,
transaction: controlSignPsbt.transaction,
});
// Broadcast the transaction to fund the target side
await broadcastChainTransaction({
lnd,
transaction: targetSignPsbt.transaction,
});
// Broadcast the transaction to fund the channel
await broadcastChainTransaction({
lnd,
transaction: finalTempTx.transaction,
});
// Mine the funding transactions into a block
await asyncRetry({interval, times}, async () => {
await control.generate({});
const {channels} = await getChannels({lnd});
if (!channels.find(n => n.is_active)) {
throw new Error('ExpectedActiveChannel');
}
return;
});
const controlChannels = await getChannels({lnd});
const [controlChannel] = controlChannels.channels;
const closeAddr = coopCloseAddress.address;
strictEqual(controlChannel.commit_transaction_fee, 2810, 'Regular tx fee');
strictEqual(controlChannel.commit_transaction_weight, 1116, 'Regular tx');
strictEqual(controlChannel.capacity, capacity, 'Channel with capacity');
strictEqual(controlChannel.cooperative_close_address, closeAddr, 'Addr');
strictEqual(!!controlChannel.cooperative_close_delay_height, true, 'Thaw');
strictEqual(!!controlChannel.id, true, 'Got channel id');
strictEqual(controlChannel.is_active, true, 'Channel is active and ready');
strictEqual(controlChannel.is_closing, false, 'Channel is not closing');
strictEqual(controlChannel.is_opening, false, 'Channel is already opened');
strictEqual(controlChannel.is_partner_initiated, false, 'Control opened');
strictEqual(controlChannel.is_private, true, 'Channel is private');
strictEqual(controlChannel.local_balance, incoming.remote_balance, 'Toks');
strictEqual(controlChannel.local_csv, 144, 'Channel CSV');
ok(controlChannel.local_dust >= 354, 'Channel dust');
strictEqual(controlChannel.local_given, giveTokens, 'Gave Channel tokens');
strictEqual(controlChannel.local_max_htlcs, 483, 'Channel HTLCs max set');
strictEqual(controlChannel.partner_public_key, target.id, 'R-key');
strictEqual(controlChannel.transaction_id, fundingTxId, 'Funding tx id');
strictEqual(controlChannel.transaction_vout, fundingTxVout, 'Tx vout');
const targetChannels = await getChannels({lnd: target.lnd});
const [targetChannel] = targetChannels.channels;
strictEqual(targetChannel.commit_transaction_fee, 2810, 'Regular tx fee');
strictEqual(targetChannel.commit_transaction_weight, 1116, 'Regular size');
strictEqual(targetChannel.capacity, capacity, 'Channel with capacity');
strictEqual(targetChannel.cooperative_close_address, undefined, 'No addr');
strictEqual(!!targetChannel.cooperative_close_delay_height, true, 'Thaw');
strictEqual(!!targetChannel.id, true, 'Got channel id');
strictEqual(targetChannel.is_active, true, 'Channel is active and ready');
strictEqual(targetChannel.is_closing, false, 'Channel is not closing');
strictEqual(targetChannel.is_opening, false, 'Channel is already opened');
strictEqual(targetChannel.is_partner_initiated, true, 'Control opened');
strictEqual(targetChannel.is_private, true, 'Channel is private');
strictEqual(targetChannel.local_balance, giveTokens, 'Target tokens');
strictEqual(targetChannel.local_csv, 144, 'Channel CSV');
ok(targetChannel.local_dust >= 354, 'Channel dust');
strictEqual(targetChannel.local_given, 0, 'No tokens given');
strictEqual(targetChannel.local_max_htlcs, 483, 'Channel HTLCs max set');
strictEqual(targetChannel.partner_public_key, control.id, 'R-key');
strictEqual(targetChannel.transaction_id, fundingTxId, 'Funding tx id');
strictEqual(targetChannel.transaction_vout, fundingTxVout, 'Tx vout');
} catch (err) {
strictEqual(err, null, 'Expected no error');
}
await kill({});
return;
});