paid-services
Version:
Lightning Paid Services library
866 lines (715 loc) • 27 kB
JavaScript
const {createHash} = require('crypto');
const asyncAuto = require('async/auto');
const asyncMap = require('async/map');
const asyncReflect = require('async/reflect');
const {broadcastChainTransaction} = require('ln-service');
const {cancelHodlInvoice} = require('ln-service');
const {confirmationFee} = require('goldengate');
const {controlBlock} = require('p2tr');
const {createChainAddress} = require('ln-service');
const {createInvoice} = require('ln-service');
const {createPsbt} = require('psbt');
const {diffieHellmanComputeSecret} = require('ln-service');
const {fundPsbt} = require('ln-service');
const {getChainFeeRate} = require('ln-service');
const {getChainTransactions} = require('ln-service');
const {getChannels} = require('ln-service');
const {getHeight} = require('ln-service');
const {getIdentity} = require('ln-service');
const {getInvoice} = require('ln-service');
const {getNetwork} = require('ln-sync');
const {getPublicKey} = require('ln-service');
const {hashForTree} = require('p2tr');
const {pointAdd} = require('tiny-secp256k1');
const {returnResult} = require('asyncjs-util');
const {sendToChainOutputScripts} = require('ln-service');
const {settleHodlInvoice} = require('ln-service');
const {signPsbt} = require('ln-service');
const {signTransaction} = require('ln-service');
const {subscribeToBlocks} = require('ln-service');
const {subscribeToInvoice} = require('ln-service');
const {subscribeToSpend} = require('goldengate');
const {swapScriptBranches} = require('goldengate');
const {taprootRefundTransaction} = require('goldengate');
const tinysecp = require('tiny-secp256k1');
const {Transaction} = require('bitcoinjs-lib');
const {v1OutputScript} = require('p2tr');
const decodeOnToOffRecovery = require('./decode_on_to_off_recovery');
const {typePayMetadata} = require('./swap_field_types');
const bufferAsHex = buffer => buffer.toString('hex');
const defaultMaxFundingConfirmationDelay = 12;
const defaultMinDelta = 60;
const estimatedSize = 150;
const family = 805;
const flatten = arr => [].concat(...arr);
const {floor} = Math;
const format = 'p2tr';
const {from} = Buffer;
const {fromHex} = Transaction;
const fuzzBlocks = 100;
const hexAsBase64 = hex => Buffer.from(hex, 'hex').toString('base64');
const hexAsBuffer = hex => Buffer.from(hex, 'hex');
const historicBlocksRange = 144 * 30;
const isAnnex = element => element[0] === 0x50;
const maxRefundMultiple = (r, t) => Math.min(100, ((1000 + t) / 150) / r);
const {min} = Math;
const minBlocks = 50;
const pollInterval = {btcregtest: 100};
const pubKeyAsInternalKey = key => Buffer.from(key).slice(1).toString('hex');
const pubKeyAsSecret = hexPublicKey => hexPublicKey.slice(2);
const removeAnnex = stack => stack.slice(0, -1);
const sha256 = preimage => createHash('sha256').update(preimage).digest('hex');
const sighash = Transaction.SIGHASH_DEFAULT;
const slowTarget = 144 * 7;
const sweepInputIndex = 0;
const times = n => Array(n).fill(null).map((_, i) => i);
const uniqBy = (a,b) => a.filter((e,i) => a.findIndex(n => n[b] == e[b]) == i);
const witnessLengthCoopSweep = 1;
const witnessLengthTimeoutSweep = 3;
/** Complete the on-chain side of the swap by taking the off-chain funds
{
[confirmation_target]: <Funding Confirmation Target Number>
emitter: <Event Emitter Object>
[is_ignoring_deposit]: <Ignore Held Deposit Funds Bool>
lnd: <Authenticated LND API Object>
recovery: <Swap Recovery Hex String>
[request]: <Request Function>
}
@returns via cbk or Promise
*/
module.exports = (args, cbk) => {
return new Promise((resolve, reject) => {
return asyncAuto({
// Import the ECPair library
ecp: async () => (await import('ecpair')).ECPairFactory(tinysecp),
// Check arguments
validate: cbk => {
if (!args.emitter) {
return cbk([400, 'ExpectedEventEmitterToCompleteOnchainSwap']);
}
if (!args.lnd) {
return cbk([400, 'ExpectedAuthenticatedLndToCompleteOnchainSwap']);
}
if (!args.recovery) {
return cbk([400, 'ExpectedRecoveryStateToCompleteOnchainSwap']);
}
return cbk();
},
// Get the chain fee rate
getChainFee: ['validate', ({}, cbk) => {
return getChainFeeRate({
confirmation_target: slowTarget,
lnd: args.lnd,
},
cbk);
}],
// Get the current block height to find the chain spend
getHeight: ['validate', ({}, cbk) => getHeight({lnd: args.lnd}, cbk)],
// Get the identity key to decrypt the recovery details
getIdentity: ['validate', ({}, cbk) => {
return getIdentity({lnd: args.lnd}, cbk);
}],
// Get the network name for generating the timeout sweeps
getNetwork: ['validate', ({}, cbk) => getNetwork({lnd: args.lnd}, cbk)],
// Get an address to sweep out to
getSweepAddress: ['validate', ({}, cbk) => {
return createChainAddress({format, lnd: args.lnd}, cbk);
}],
// Get the encryption key to decode the recovery secrets
getDecrypt: ['getIdentity', ({getIdentity}, cbk) => {
return diffieHellmanComputeSecret({
lnd: args.lnd,
partner_public_key: getIdentity.public_key,
},
cbk);
}],
// Get chain transactions
getTransactions: ['recoveryDetails', ({recoveryDetails}, cbk) => {
return getChainTransactions({
after: recoveryDetails.timeout - historicBlocksRange,
lnd: args.lnd,
},
cbk);
}],
// Recovery details
recoveryDetails: ['getDecrypt', ({getDecrypt}, cbk) => {
const decrypt = getDecrypt.secret;
try {
const details = decodeOnToOffRecovery({
decrypt,
recovery: args.recovery,
});
return cbk(null, {
claim_coop_public_key_hash: details.claim_coop_public_key_hash,
claim_solo_public_key: details.claim_solo_public_key,
hash: details.hash,
key_index: details.key_index,
incoming_peer: details.incoming_peer || undefined,
refund_coop_private_key: details.refund_coop_private_key,
refund_coop_private_key_hash: details.refund_coop_private_key_hash,
solo_private_key: details.refund_solo_private_key,
timeout: details.timeout,
tokens: details.tokens,
});
} catch (err) {
return cbk([400, 'ExpectedValidOnToOffRecoveryDetails', {err}]);
}
}],
// Lookup channel ids with the incoming peer when applicable
getChannels: ['recoveryDetails', ({recoveryDetails}, cbk) => {
if (!recoveryDetails.incoming_peer) {
return cbk();
}
return getChannels({
lnd: args.lnd,
partner_public_key: recoveryDetails.incoming_peer,
},
cbk);
}],
// Get the refund key
getRefundKey: [
'ecp',
'recoveryDetails',
({ecp, recoveryDetails}, cbk) =>
{
// Exit early when a unilateral private key is defined
if (!!recoveryDetails.solo_private_key) {
const privateKey = hexAsBuffer(recoveryDetails.solo_private_key);
const {publicKey} = ecp.fromPrivateKey(privateKey);
return cbk(null, {public_key: bufferAsHex(publicKey)});
}
return getPublicKey({
family,
index: recoveryDetails.key_index,
lnd: args.lnd,
},
cbk);
}],
// Wait for the deposit hold which also contains the cooperative pubkey
waitForDepositHold: ['recoveryDetails', ({recoveryDetails}, cbk) => {
const id = recoveryDetails.refund_coop_private_key_hash;
const sub = subscribeToInvoice({id, lnd: args.lnd});
args.emitter.emit('update', {waiting_for_execution_payment_to: id});
sub.on('error', err => {
sub.removeAllListeners();
return cbk(err);
});
sub.on('invoice_updated', invoice => {
// Exit early when the payment has not arrived yet
if (!invoice.is_held) {
return;
}
const channels = invoice.payments.map(n => n.in_channel);
const hash = recoveryDetails.claim_coop_public_key_hash;
const timeout = min(...invoice.payments.map(n => n.timeout));
const message = flatten(invoice.payments.map(n => n.messages))
.filter(({type}) => type === typePayMetadata)
.find(({value}) => sha256(hexAsBuffer(value)) === hash);
// Exit early when there is no cooperative key
if (!message) {
return;
}
const delta = timeout - recoveryDetails.timeout;
sub.removeAllListeners();
args.emitter.emit('update', {execution_payment_held: id});
return cbk(null, {
channels,
delta,
claim_coop_public_key: message.value,
});
});
}],
// Wait for off chain funding
waitForFundHold: ['recoveryDetails', ({recoveryDetails}, cbk) => {
const id = recoveryDetails.hash;
args.emitter.emit('update', {waiting_for_offchain_funds: id});
const sub = subscribeToInvoice({id, lnd: args.lnd});
sub.on('error', err => {
sub.removeAllListeners();
return cbk(err);
});
sub.on('invoice_updated', invoice => {
// Exit early when the payment has not arrived yet
if (!invoice.is_held) {
return;
}
args.emitter.emit('update', {offchain_funding_held: id});
const channels = invoice.payments.map(n => n.in_channel);
const timeout = min(...invoice.payments.map(n => n.timeout));
const delta = timeout - recoveryDetails.timeout;
sub.removeAllListeners();
return cbk(null, {channels, delta});
});
return;
}],
// Derive swap details
swap: [
'ecp',
'getRefundKey',
'recoveryDetails',
'waitForDepositHold',
({ecp, getRefundKey, recoveryDetails, waitForDepositHold}, cbk) =>
{
const privateKey = recoveryDetails.refund_coop_private_key;
const jointPublicKey = pointAdd(
ecp.fromPrivateKey(hexAsBuffer(privateKey)).publicKey,
hexAsBuffer(waitForDepositHold.claim_coop_public_key)
);
const swapScript = swapScriptBranches({
ecp,
claim_public_key: recoveryDetails.claim_solo_public_key,
hash: recoveryDetails.hash,
refund_public_key: getRefundKey.public_key,
timeout: recoveryDetails.timeout,
});
const output = v1OutputScript({
hash: hashForTree({branches: swapScript.branches}).hash,
internal_key: bufferAsHex(from(jointPublicKey)),
});
return cbk(null, {
external_key: output.external_key,
hash: recoveryDetails.hash,
internal_key: pubKeyAsInternalKey(jointPublicKey),
output_script: output.script,
refund_script: swapScript.refund,
script_branches: swapScript.branches,
tokens: recoveryDetails.tokens,
});
}],
// Generate the refund transactions against the inbound HTLC deadline
refunds: [
'ecp',
'getChainFee',
'getNetwork',
'getRefundKey',
'getSweepAddress',
'outpoint',
'recoveryDetails',
'swap',
'waitForFundHold',
({
ecp,
getChainFee,
getNetwork,
getRefundKey,
getSweepAddress,
outpoint,
recoveryDetails,
swap,
waitForFundHold,
},
cbk) =>
{
const before = waitForFundHold.delta;
const fee = getChainFee.tokens_per_vbyte;
const multiplier = maxRefundMultiple(fee, recoveryDetails.tokens);
const feeRates = times(before).map(cursor => {
const {rate} = confirmationFee({before, cursor, fee, multiplier});
return {
rate: floor(rate),
height: recoveryDetails.timeout + cursor,
};
});
const refunds = uniqBy(feeRates, 'rate').map(({rate, height}) => {
// Exit early when there is nothing to recover
if (rate * estimatedSize > recoveryDetails.tokens) {
return;
}
return taprootRefundTransaction({
ecp,
block_height: height,
external_key: swap.external_key,
fee_tokens_per_vbyte: rate,
internal_key: swap.internal_key,
network: getNetwork.network,
output_script: swap.output_script,
private_key: recoveryDetails.solo_private_key,
refund_script: swap.refund_script,
script_branches: swap.script_branches,
sweep_address: getSweepAddress.address,
tokens: recoveryDetails.tokens,
transaction_id: outpoint.id,
transaction_vout: outpoint.vout,
});
});
return cbk(null, refunds.filter(n => !!n).map(n => n.transaction));
}],
// Sign the refund transactions
signRefunds: [
'recoveryDetails',
'refunds',
'swap',
({recoveryDetails, refunds, swap}, cbk) =>
{
// Exit early when refunds are already signed
if (!!recoveryDetails.solo_private_key) {
return cbk(null, refunds);
}
return asyncMap(refunds, (transaction, cbk) => {
return signTransaction({
transaction,
inputs: [{
sighash,
key_family: family,
key_index: recoveryDetails.key_index,
output_script: swap.output_script,
output_tokens: recoveryDetails.tokens,
vin: sweepInputIndex,
witness_script: swap.refund_script,
}],
lnd: args.lnd,
},
(err, res) => {
if (!!err) {
return cbk(err);
}
const [signature] = res.signatures;
const {block} = controlBlock({
internal_key: swap.internal_key,
external_key: swap.external_key,
leaf_script: swap.refund_script,
script_branches: swap.script_branches,
});
const witness = [signature, swap.refund_script, block];
const tx = fromHex(transaction);
// Add the signature to the sweep
tx.ins.forEach((input, vin) => {
return tx.setWitness(vin, witness.map(hexAsBuffer));
});
return cbk(null, tx.toHex());
});
},
cbk);
}],
// Check incoming peer constraints
checkIncomingPeer: [
'getChannels',
'waitForDepositHold',
'waitForFundHold',
({getChannels, waitForDepositHold, waitForFundHold}, cbk) =>
{
// Exit early when there are no incoming peer constraints
if (!args.incoming_peer) {
return cbk();
}
const channels = getChannels.channels.map(n => n.id);
// The deposit must come in under a channel with the incoming peer
const invalidDeposit = waitForDepositHold.channels.find(id => {
return !channels.includes(id);
});
if (!!invalidDeposit) {
return cbk([503, 'UnexpectedInboundChannelForHeldDeposit']);
}
// The funding must come in under a channel with the incoming peer
const invalidFunding = waitForFundHold.channels.find(id => {
return !channels.includes(id);
});
if (!!invalidFunding) {
return cbk([503, 'UnexpectedInboundChannelForHeldFunding']);
}
return cbk();
}],
// Check that there are sufficient remaining blocks
checkTimeRemaining: [
'recoveryDetails',
'waitForDepositHold',
'waitForFundHold',
({
recoveryDetails,
waitForDepositHold,
waitForFundHold,
},
cbk) =>
{
if (waitForDepositHold.delta < defaultMinDelta) {
return cbk([503, 'InsufficientDepositDeltaBlocksToFundSwap']);
}
if (waitForFundHold.delta < defaultMinDelta) {
return cbk([503, 'InsufficientFundingDeltaBlocksToFundSwap']);
}
return getHeight({lnd: args.lnd}, (err, res) => {
if (!!err) {
return cbk(err);
}
const delta = recoveryDetails.timeout - res.current_block_height;
args.emitter.emit('update', {blocks_remaining_until_timeout: delta});
// The blocks until timeout have to be sufficient for confirm & sweep
if (delta < minBlocks) {
return cbk([400, 'InsufficientBlocksRemainingForSwap']);
}
return cbk();
});
}],
// Lock chain funds to fund the swap with
lockFunding: [
'checkIncomingPeer',
'checkTimeRemaining',
'getTransactions',
'swap',
({getTransactions, swap}, cbk) =>
{
const tx = getTransactions.transactions.find(({transaction}) => {
return !!transaction && !!fromHex(transaction).outs.find(output => {
if (output.value !== swap.tokens) {
return false;
}
return bufferAsHex(output.script) === swap.output_script;
});
});
// Exit early when there is already a funding tx
if (!!tx) {
return cbk(null, {transaction: tx.transaction});
}
// Create a PSBT to specify the output P2TR
const {psbt} = createPsbt({
outputs: [{script: swap.output_script, tokens: swap.tokens}],
utxos: [],
});
return fundPsbt({
psbt,
lnd: args.lnd,
target_confirmations: args.confirmation_target,
},
cbk);
}],
// Sign the on-chain funding to get a signed raw tx
signFunding: ['lockFunding', ({lockFunding}, cbk) => {
if (!!lockFunding.transaction) {
return cbk(null, {
is_already_broadcast: true,
transaction: lockFunding.transaction,
});
}
return signPsbt({lnd: args.lnd, psbt: lockFunding.psbt}, cbk);
}],
// Output funding the swap on chain
outpoint: ['signFunding', 'swap', ({signFunding, swap}, cbk) => {
const tx = fromHex(signFunding.transaction);
const script = hexAsBuffer(swap.output_script);
const id = tx.getId();
const vout = tx.outs.findIndex(n => n.script.equals(script));
args.emitter.emit('update', {
funding_transaction: signFunding.transaction,
funding_transaction_id: id,
funding_transaction_vout: vout,
});
return cbk(null, {id, vout});
}],
// Broadcast the funding transaction
broadcastFunding: [
'signFunding',
'signRefunds',
({signFunding, signRefunds}, cbk) =>
{
const [refund] = signRefunds;
args.emitter.emit('update', {
refund_transaction: refund,
refund_valid_at: fromHex(refund).locktime,
});
// Exit early when the transaction was already published
if (!!signFunding.is_already_broadcast) {
return cbk();
}
return broadcastChainTransaction({
description: `swap funding. refund: ${hexAsBase64(refund)}`,
lnd: args.lnd,
transaction: signFunding.transaction,
},
cbk);
}],
// Wait for the preimage push or the claim spend
findSecret: [
'getHeight',
'getNetwork',
'outpoint',
'recoveryDetails',
'signRefunds',
'swap',
({
getHeight,
getNetwork,
outpoint,
recoveryDetails,
signRefunds,
swap,
},
cbk) =>
{
const id = recoveryDetails.claim_coop_public_key_hash;
const subPush = subscribeToInvoice({id, lnd: args.lnd});
const subSpend = subscribeToSpend({
delay_ms: pollInterval[getNetwork.network],
lnd: args.lnd,
min_height: getHeight.current_block_height - fuzzBlocks,
network: getNetwork.network,
output_script: swap.output_script,
request: args.request,
transaction_id: outpoint.id,
transaction_vout: outpoint.vout,
});
const subTimeout = subscribeToBlocks({lnd: args.lnd});
const done = (err, res) => {
subPush.removeAllListeners();
subSpend.removeAllListeners();
subTimeout.removeAllListeners();
return cbk(err, res);
};
subPush.on('error', err => done(err));
subPush.on('invoice_updated', invoice => {
// Exit early when the invoice isn't held
if (!invoice.is_held) {
return;
}
const {messages} = invoice.payments.find(payment => {
return !!payment.messages.find(n => n.type === typePayMetadata);
});
const message = messages.find(n => n.type === typePayMetadata);
if (!message) {
return;
}
if (sha256(hexAsBuffer(message.value)) !== recoveryDetails.hash) {
return;
}
const secret = message.value;
args.emitter.emit('update', {received_cooperative_secret: secret});
return done(null, {secret});
});
// Look for the preimage to be used on-chain in a sweep
subSpend.on('confirmation', ({transaction}) => {
const spend = fromHex(transaction).ins.find(input => {
if (!input.hash.equals(hexAsBuffer(outpoint.id).reverse())) {
return false;
}
return input.index === outpoint.vout;
});
const [lastElement, nextToLast] = spend.witness.slice().reverse();
const hasAnnex = isAnnex(lastElement) && !!nextToLast;
// Only consider the witness stack without the annex element
const stack = hasAnnex ? removeAnnex(spend.witness) : spend.witness;
// Exit early when swap is spent with a signature
if (stack.length === witnessLengthCoopSweep) {
args.emitter.emit('update', {swap_coop_success: transaction});
return;
}
// Exit early when swap is spent with a timeout
if (stack.length === witnessLengthTimeoutSweep) {
args.emitter.emit('update', {swap_timeout_complete: transaction});
// Don't bother waiting for swap funding anymore
return cancelHodlInvoice({
id: recoveryDetails.hash,
lnd: args.lnd,
},
() => {
return done([503, 'SwapFailedViaTimeout']);
});
}
args.emitter.emit('update', {swap_peer_solo_success: transaction});
const [secret] = stack;
return done(null, {secret: bufferAsHex(secret)});
});
subSpend.on('error', err => done(err));
subTimeout.on('block', block => {
const delta = recoveryDetails.timeout - block.height;
args.emitter.emit('update', {blocks_until_timeout: delta});
if (block.height < recoveryDetails.timeout) {
return;
}
// Find the latest valid refund transaction
const [transaction] = signRefunds
.filter(transaction => {
return fromHex(transaction).locktime <= block.height;
})
.reverse();
args.emitter.emit('update', {broadcasting_refund: transaction});
return broadcastChainTransaction({
transaction,
lnd: args.lnd,
},
() => {});
});
subTimeout.on('error', err => done(err));
return;
}],
// Cancel the preimage push back to the sender
cancelPushHold: [
'findSecret',
'recoveryDetails',
asyncReflect(({findSecret, recoveryDetails}, cbk) =>
{
return cancelHodlInvoice({
id: recoveryDetails.claim_coop_public_key_hash,
lnd: args.lnd,
},
cbk);
})],
// Get the status of the funding
getSettlement: ['findSecret', 'swap', ({swap}, cbk) => {
return getInvoice({id: swap.hash, lnd: args.lnd}, cbk);
}],
// Settle the incoming funds HTLC
takeFunding: [
'findSecret',
'getSettlement',
({findSecret, getSettlement}, cbk) =>
{
// Exit early when invoice is already settled
if (!!getSettlement.is_confirmed) {
return cbk();
}
args.emitter.emit('update', {receiving_funds: getSettlement.tokens});
return settleHodlInvoice({
lnd: args.lnd,
secret: findSecret.secret,
},
cbk);
}],
// Settle the incoming deposit HTLC after funding is taken
takeDeposit: [
'recoveryDetails',
'takeFunding',
'waitForDepositHold',
({recoveryDetails}, cbk) =>
{
// Exit early when ignoring the deposit
if (!!args.is_ignoring_deposit) {
return cbk();
}
const privateKey = recoveryDetails.refund_coop_private_key;
args.emitter.emit('update', {sending_cooperative_privkey: privateKey});
return settleHodlInvoice({lnd: args.lnd, secret: privateKey}, cbk);
}],
// Get the settled deposit
getDeposit: [
'recoveryDetails',
'takeDeposit',
asyncReflect(({recoveryDetails}, cbk) =>
{
const id = recoveryDetails.refund_coop_private_key_hash;
return getInvoice({id, lnd: args.lnd}, cbk);
})],
// Look up the funding to see how much was settled
getFunding: ['swap', 'takeFunding', asyncReflect(({swap}, cbk) => {
return getInvoice({id: swap.hash, lnd: args.lnd}, cbk);
})],
// Summary of swap results
summary: [
'getDeposit',
'getFunding',
'outpoint',
'recoveryDetails',
({getDeposit, getFunding, outpoint, recoveryDetails}, cbk) =>
{
const deposit = getDeposit.value || {};
const funding = getFunding.value || {};
return cbk(null, {
funded_transaction_id: outpoint.id,
funded_transaction_vout: outpoint.vout,
received_for_swap_execution: deposit.received,
received_for_swap_funding: funding.received,
send_to_fund_swap_onchain: recoveryDetails.tokens,
});
}],
},
returnResult({reject, resolve, of: 'summary'}, cbk));
});
};