ln-service
Version:
Interaction helper for your Lightning Network daemon
421 lines (325 loc) • 12.9 kB
JavaScript
const {equal} = require('node:assert').strict;
const test = require('node:test');
const asyncRetry = require('async/retry');
const {address} = require('bitcoinjs-lib');
const {controlBlock} = require('p2tr');
const {createPsbt} = require('psbt');
const {decodePsbt} = require('psbt');
const {hashForTree} = require('p2tr');
const {leafHash} = require('p2tr');
const {networks} = require('bitcoinjs-lib');
const {pointAdd} = require('tiny-secp256k1');
const {privateAdd} = require('tiny-secp256k1');
const {scriptElementsAsScript} = require('@alexbosworth/blockchain');
const {signHash} = require('p2tr');
const {signSchnorr} = require('tiny-secp256k1');
const {spawnLightningCluster} = require('ln-docker-daemons');
const tinysecp = require('tiny-secp256k1');
const {Transaction} = require('bitcoinjs-lib');
const {v1OutputScript} = require('p2tr');
const {broadcastChainTransaction} = require('./../../');
const {createChainAddress} = require('./../../');
const {fundPsbt} = require('./../../');
const {getChainBalance} = require('./../../');
const {getChainTransactions} = require('./../../');
const {getUtxos} = require('./../../');
const {sendToChainAddress} = require('./../../');
const {signPsbt} = require('./../../');
const chainAddressRowType = 'chain_address';
const compile = elements => scriptElementsAsScript({elements}).script;
const confirmationCount = 6;
const count = 100;
const defaultInternalKey = '0350929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0';
const description = 'description';
const {from} = Buffer;
const {fromBech32} = address;
const {fromHex} = Transaction;
const {fromOutputScript} = address;
const hexAsBuffer = hex => Buffer.from(hex, 'hex');
const interval = retryCount => 10 * Math.pow(2, retryCount);
const OP_CHECKSIG = 172;
const regtestBech32AddressHrp = 'bcrt';
const smallTokens = 2e5;
const times = 20;
const {toOutputScript} = address;
const tokens = 1e6;
const txIdHexByteLength = 64;
// Funding a transaction should result in a funded PSBT
test(`Fund PSBT`, async () => {
const ecp = (await import('ecpair')).ECPairFactory(tinysecp);
const {kill, nodes} = await spawnLightningCluster({});
const [{generate, lnd}] = nodes;
await generate({count});
const {address} = await createChainAddress({lnd});
const [utxo] = (await getUtxos({lnd})).utxos;
const funded = await asyncRetry({interval, times}, async () => {
try {
return await fundPsbt({
lnd,
inputs: [{
transaction_id: utxo.transaction_id,
transaction_vout: utxo.transaction_vout,
}],
outputs: [{address, tokens}],
});
} catch (err) {
// On LND 0.11.1 and below, funding a PSBT is not supported
if (err.slice().shift() === 501) {
return;
}
throw err;
}
});
const [input] = funded.inputs;
equal(funded.inputs.length, [utxo].length, 'Got expected number of inputs');
equal(input.transaction_id, utxo.transaction_id, 'Got expected input tx id');
equal(input.transaction_vout, utxo.transaction_vout, 'Got expected tx vout');
equal(input.lock_expires_at > new Date().toISOString(), true, 'Got expires');
equal(input.lock_id.length, 64, 'Got lock identifier');
equal(funded.outputs.length, 2, 'Got expected output count');
const change = funded.outputs.find(n => n.is_change);
const output = funded.outputs.find(n => !n.is_change);
// LND 0.15.4 and below use P2WPKH as change
if (change.output_script.length === 44) {
equal(change.output_script.length, 44, 'Change address is returned');
equal(change.tokens, 4998992950, 'Got change output value');
} else if (change.tokens === 4998992350) { // LND 0.18.5 and below
equal(change.output_script.length, 68, 'Change address is returned');
equal(change.tokens, 4998992350, 'Got change output value');
} else {
equal(change.output_script.length, 68, 'Change address is returned');
equal(change.tokens, 4998996175, 'Got change output value');
}
equal(output.tokens, tokens, 'Got expected tokens output');
const {data, version} = fromBech32(address);
const prefix = `${Buffer.from([version]).toString('hex')}14`;
const expectedOutput = `${prefix}${data.toString('hex')}`;
equal(output.output_script, expectedOutput, 'Got expected output script');
const decoded = decodePsbt({ecp, psbt: funded.psbt});
const [decodedInput] = decoded.inputs;
equal(decodedInput.sighash_type, 1, 'PSBT has sighash all flag');
equal(!!decodedInput.witness_utxo.script_pub, true, 'PSBT input address');
equal(decodedInput.witness_utxo.tokens, 5000000000, 'PSBT has input tokens');
// A Taproot script can be funded and spent with internal key + script hash
try {
await generate({count});
const keyPair1 = ecp.makeRandom({network: networks.regtest});
const keyPair2 = ecp.makeRandom({network: networks.regtest});
const unusedKey = ecp.makeRandom({network: networks.regtest});
const witnessScript = compile([
from(unusedKey.publicKey).slice(1),
OP_CHECKSIG,
]);
const branches = [{script: witnessScript}];
const {hash} = hashForTree({branches});
// Create a combined key using public key material
const combinedPoint = pointAdd(
from(keyPair1.publicKey),
from(keyPair2.publicKey)
);
const output = v1OutputScript({
hash,
internal_key: Buffer.from(combinedPoint).toString('hex'),
});
const [utxo] = (await getUtxos({lnd})).utxos.reverse();
// Make a PSBT paying to the Taproot output
const {psbt} = createPsbt({
outputs: [{tokens, script: output.script}],
utxos: [{id: utxo.transaction_id, vout: utxo.transaction_vout}],
});
// Sign the PSBT
const signed = await signPsbt({
lnd,
psbt: (await fundPsbt({lnd, psbt})).psbt,
});
// Send the tx to the chain
await broadcastChainTransaction({lnd, transaction: signed.transaction});
// Make a new tx that will spend the output back into the wallet
const tx = new Transaction();
// The new tx spends the Taproot output
tx.addInput(
fromHex(signed.transaction).getHash(),
fromHex(signed.transaction).outs.findIndex(n => n.value === tokens)
);
// Make an output to pay back into the wallet
const chainOutput = toOutputScript(
(await createChainAddress({lnd})).address,
networks.regtest
);
// Add output to the pay back transaction
tx.addOutput(chainOutput, smallTokens);
const [hashToSign] = tx.ins.map((input, i) => {
return tx.hashForWitnessV1(
i,
[hexAsBuffer(output.script)],
[tokens],
Transaction.SIGHASH_DEFAULT,
);
});
// Ready for private key combining
const combinedKey = privateAdd(
from(keyPair1.privateKey),
from(keyPair2.privateKey)
);
const signedInput = signHash({
hash,
private_key: Buffer.from(combinedKey).toString('hex'),
public_key: Buffer.from(combinedPoint).toString('hex'),
sign_hash: hashToSign.toString('hex'),
});
const signature = hexAsBuffer(signedInput.signature);
// Add the signature to the input
tx.ins.forEach((input, i) => tx.setWitness(i, [signature]));
await broadcastChainTransaction({lnd, transaction: tx.toHex()});
await asyncRetry({interval, times}, async () => {
await generate({});
const {utxos} = await getUtxos({lnd});
const utxo = utxos.find(n => n.transaction_id === tx.getId());
if (!utxo || !utxo.confirmation_count) {
throw new Error('ExpectedReceivedTaprootSpend');
}
});
} catch (err) {
equal(err, null, 'Expected no error');
}
// A Taproot script output should be funded and spent with script
try {
await generate({count});
const keyPair = ecp.makeRandom({network: networks.regtest});
const witnessScript = compile([
from(keyPair.publicKey.slice(1)),
OP_CHECKSIG,
]);
const branches = [{script: witnessScript}];
const {hash} = hashForTree({branches});
const output = v1OutputScript({hash, internal_key: defaultInternalKey});
const [utxo] = (await getUtxos({lnd})).utxos.reverse();
// Make a PSBT paying to the Taproot output
const {psbt} = createPsbt({
outputs: [{tokens, script: output.script}],
utxos: [{id: utxo.transaction_id, vout: utxo.transaction_vout}],
});
// Sign the PSBT
const signed = await signPsbt({
lnd,
psbt: (await fundPsbt({lnd, psbt})).psbt,
});
// Send the tx to the chain
await broadcastChainTransaction({lnd, transaction: signed.transaction});
// Make a new tx that will spend the output back into the wallet
const tx = new Transaction();
// The new tx spends the Taproot output
tx.addInput(
fromHex(signed.transaction).getHash(),
fromHex(signed.transaction).outs.findIndex(n => n.value === tokens)
);
// Make an output to pay back into the wallet
const chainOutput = toOutputScript(
(await createChainAddress({lnd})).address,
networks.regtest
);
// Add output to the pay back transaction
tx.addOutput(chainOutput, smallTokens);
const [hashToSign] = tx.ins.map((input, i) => {
return tx.hashForWitnessV1(
i,
[hexAsBuffer(output.script)],
[tokens],
Transaction.SIGHASH_DEFAULT,
hexAsBuffer(leafHash({script: witnessScript}).hash),
);
});
const signature = from(signSchnorr(hashToSign, from(keyPair.privateKey)));
const {block} = controlBlock({
external_key: output.external_key,
leaf_script: witnessScript,
script_branches: branches,
});
// Add the signature to the input
tx.ins.forEach((input, i) => {
return tx.setWitness(i, [
signature,
hexAsBuffer(witnessScript),
hexAsBuffer(block),
]);
});
await broadcastChainTransaction({lnd, transaction: tx.toHex()});
await asyncRetry({interval, times}, async () => {
await generate({});
const {utxos} = await getUtxos({lnd});
const utxo = utxos.find(n => n.transaction_id === tx.getId());
if (!utxo || !utxo.confirmation_count) {
throw new Error('ExpectedReceivedTaprootSpend');
}
});
} catch (err) {
await kill({});
equal(err, null, 'Expected no error');
return;
}
// A Taproot output should be funded for a regular key spend
try {
await generate({count});
const keyPair = ecp.makeRandom({network: networks.regtest});
const output = v1OutputScript({
internal_key: from(keyPair.publicKey).toString('hex'),
});
const outputScript = hexAsBuffer(output.script);
const [utxo] = (await getUtxos({lnd})).utxos.reverse();
// Make a PSBT paying to the Taproot output
const {psbt} = createPsbt({
outputs: [{tokens, script: outputScript.toString('hex')}],
utxos: [{id: utxo.transaction_id, vout: utxo.transaction_vout}],
});
// Sign the PSBT
const signed = await signPsbt({
lnd,
psbt: (await fundPsbt({lnd, psbt})).psbt,
});
// Send the tx to the chain
await broadcastChainTransaction({lnd, transaction: signed.transaction});
// Make a new tx that will spend the output back into the wallet
const tx = new Transaction();
// The new tx spends the Taproot output
tx.addInput(
fromHex(signed.transaction).getHash(),
fromHex(signed.transaction).outs.findIndex(n => n.value === tokens)
);
// Make an output to pay back into the wallet
const chainOutput = toOutputScript(
(await createChainAddress({lnd})).address,
networks.regtest
);
// Add output to the pay back transaction
tx.addOutput(chainOutput, smallTokens);
const [hashToSign] = tx.ins.map((input, i) => {
return tx.hashForWitnessV1(
i,
[outputScript],
[tokens],
Transaction.SIGHASH_DEFAULT,
);
});
const signedInput = signHash({
private_key: from(keyPair.privateKey).toString('hex'),
public_key: from(keyPair.publicKey).toString('hex'),
sign_hash: hashToSign.toString('hex'),
});
const signature = hexAsBuffer(signedInput.signature);
// Add the signature to the input
tx.ins.forEach((input, i) => tx.setWitness(i, [Buffer.from(signature)]));
await broadcastChainTransaction({lnd, transaction: tx.toHex()});
await asyncRetry({interval, times}, async () => {
await generate({});
const {utxos} = await getUtxos({lnd});
const utxo = utxos.find(n => n.transaction_id === tx.getId());
if (!utxo || !utxo.confirmation_count) {
throw new Error('ExpectedReceivedTaprootSpend');
}
});
} catch (err) {
equal(err, null, 'Expected no error');
}
await kill({});
return;
});