paid-services
Version:
Lightning Paid Services library
1,325 lines (1,132 loc) • 42.5 kB
JavaScript
const {createHash} = require('crypto');
const {randomBytes} = require('crypto');
const asyncAuto = require('async/auto');
const asyncMap = require('async/map');
const asyncMapSeries = require('async/mapSeries');
const asyncRace = require('async/race');
const asyncReflect = require('async/reflect');
const asyncTimeout = require('async/timeout');
const {bech32m} = require('bech32');
const {beginGroupSigningSession} = require('ln-service');
const {broadcastChainTransaction} = require('ln-service');
const {cancelSwapOut} = require('goldengate');
const {confirmationFee} = require('goldengate');
const {controlBlock} = require('p2tr');
const {createChainAddress} = require('ln-service');
const {diffieHellmanComputeSecret} = require('ln-service');
const {findConfirmedOutput} = require('ln-sync');
const {findDeposit} = require('goldengate');
const {getChainFeeRate} = require('ln-service');
const {getCoopSignedTx} = require('goldengate');
const {getHeight} = require('ln-service');
const {getIdentity} = require('ln-service');
const {getNetwork} = require('ln-sync');
const {getPayment} = require('ln-service');
const {getPublicKey} = require('ln-service');
const {hashForTree} = require('p2tr');
const {lightningLabsSwapAuth} = require('goldengate');
const {lightningLabsSwapService} = require('goldengate');
const {networks} = require('bitcoinjs-lib');
const {parsePaymentRequest} = require('ln-service');
const {pay} = require('ln-service');
const {payViaPaymentDetails} = require('ln-service');
const {pointAdd} = require('tiny-secp256k1');
const {privateAdd} = require('tiny-secp256k1');
const {releaseSwapOutSecret} = require('goldengate');
const {returnResult} = require('asyncjs-util');
const {script} = require('bitcoinjs-lib');
const {signTransaction} = require('ln-service');
const {subscribeToBlocks} = require('ln-service');
const {subscribeToPastPayment} = require('ln-service');
const {subscribeToSpend} = require('goldengate');
const {swapScriptBranches} = require('goldengate');
const {taprootClaimTransaction} = require('goldengate');
const {taprootCoopTransaction} = require('goldengate');
const tinysecp = require('tiny-secp256k1');
const {Transaction} = require('bitcoinjs-lib');
const {v1OutputScript} = require('p2tr');
const decodeLoopResponse = require('./decode_loop_response');
const decodeOffToOnRecovery = require('./decode_off_to_on_recovery');
const decodeOffToOnResponse = require('./decode_off_to_on_response');
const {typePayMetadata} = require('./swap_field_types');
const bufferAsHex = buffer => buffer.toString('hex');
const {ceil} = Math;
const decompileOutputScript = hex => script.decompile(Buffer.from(hex, 'hex'));
const defaultConfsCount = 1;
const defaultDepositSettleTimeoutMs = 1000 * 60 * 10;
const defaultMaxFeeMultiplier = 1000;
const defaultMaxPreimagePushFee = 10;
const defaultMinSweepBlocks = 20;
const defaultWaitForChainFundingMs = 1000 * 60 * 60 * 3;
const encodeAddress = (prefix, data) => bech32m.encode(prefix, data);
const externalKeyAsOutputScript = key => `5120${key}`;
const family = 805;
const {floor} = Math;
const format = 'p2tr';
const {from} = Buffer;
const {fromHex} = Transaction;
const fuzzBlocks = 100;
const fuzzTimelock = 1;
const getLoopCoopSignedTransaction = asyncTimeout(getCoopSignedTx, 1000 * 30);
const hexAsBuffer = hex => Buffer.from(hex, 'hex');
const {isArray} = Array;
const maxClaimMultiple = (r, t) => Math.min(1000, ((1000 + t) / 150) / r);
const maxCoopMultiple = (r, t) => Math.min(1000, ((1000 + t) / 100) / r);
const messageRejected = 'PaymentRejectedByDestination';
const {min} = Math;
const minTokens = 10000;
const minBlockMs = 1000 * 60;
const pollInterval = {btcregtest: 100};
const ppmRate = (fee, total) => fee * 1e6 / total;
const preimageByteLength = 32;
const pubKeyAsInternalKey = key => Buffer.from(key).slice(1).toString('hex');
const pushSecret = asyncTimeout(releaseSwapOutSecret, 1000 * 30);
const sha256 = preimage => createHash('sha256').update(preimage).digest('hex');
const sighash = Transaction.SIGHASH_DEFAULT;
const slowConfs = 144 * 7;
const sumOf = arr => arr.reduce((sum, n) => sum + n, 0);
const sweepInputIndex = 0;
const times = n => Array(n).fill(null).map((_, i) => i);
const tokensForPushPreimage = 1;
const uniqBy = (a,b) => a.filter((e,i) => a.findIndex(n => n[b] == e[b]) == i);
const v1AddressWords = key => [].concat(1).concat(bech32m.toWords(key));
/** Complete the off to on swap
{
emitter: <Event Emitter Object>
[is_avoiding_broadcast]: <Avoid Sweep Broadcast Bool>
[is_external_funding]: <Externally Fund Swap Bool>
[is_loop_service]: <Complete Swap With Lightning Loop Service Bool>
[is_uncooperative]: <Avoid Cooperative Signing Bool>
lnd: <Autenticated LND API Object>
max_fee_deposit: <Max Routing Fee Tokens For Deposit Number>
max_fee_funding: <Max Routing Fee Tokens For Funding Number>
[min_confirmations]: <Confirmation Blocks Number>
recovery: <Swap Request Recovery Hex String>
[request]: <Request Function>
response: <Swap Response Hex String>
[sweep_address]: <Sweep Chain Address String>
}
@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, 'ExpectedAuthenticatedLndToCompleteOffToOnSwap']);
}
if (args.max_fee_deposit === undefined) {
return cbk([400, 'ExpectedMaxRoutingFeeForDepositInOffToOnSwap']);
}
if (args.max_fee_funding === undefined) {
return cbk([400, 'ExpectedMaxRoutingFeeForFundingOffToOnSwap']);
}
if (!args.recovery) {
return cbk([400, 'ExpectedRecoveryDetailsToCompleteOffToOnSwap']);
}
if (!args.response) {
return cbk([400, 'ExpectedRequestResponseToCompleteOffToOnSwap']);
}
return cbk();
},
// Create a sweep address
createAddress: ['validate', ({}, cbk) => {
// Exit early when there is no need to create a sweep address
if (!!args.sweep_address) {
return cbk(null, {address: args.sweep_address});
}
return createChainAddress({format, lnd: args.lnd}, cbk);
}],
// Get the current chain fee rate for the sweep fee rate calculation
getFeeRate: ['validate', ({}, cbk) => {
return getChainFeeRate({
confirmation_target: slowConfs,
lnd: args.lnd,
},
cbk);
}],
// Get the current height of the chain for start height calculations
getHeight: ['validate', ({}, cbk) => getHeight({lnd: args.lnd}, cbk)],
// Get the self public key to use for the decryption key
getIdentity: ['validate', ({}, cbk) => {
return getIdentity({lnd: args.lnd}, cbk);
}],
// Get the network to use for parsing payment requests and addresses
getNetwork: ['validate', ({}, cbk) => getNetwork({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);
}],
// Decode the request details
requestDetails: ['getDecrypt', ({getDecrypt}, cbk) => {
try {
const details = decodeOffToOnRecovery({
decrypt: getDecrypt.secret,
recovery: args.recovery,
});
if (details.tokens < minTokens) {
return cbk([400, 'ExpectedHigherAmountToSwap']);
}
return cbk(null, {
coop_private_key: details.coop_private_key,
key_index: details.key_index,
secret: details.secret,
solo_private_key: details.solo_private_key,
tokens: details.tokens,
});
} catch (err) {
return cbk([400, 'FailedToDecodeRequestDetails', {err}]);
}
}],
// Decode the response details
responseDetails: [
'getNetwork',
'requestDetails',
({getNetwork, requestDetails}, cbk) =>
{
// Exit early when using the Lightning Loop service
if (!!args.is_loop_service) {
try {
const details = decodeLoopResponse({
network: getNetwork.bitcoinjs,
response: args.response,
});
return cbk(null, {
auth_macaroon: details.auth_macaroon,
auth_preimage: details.auth_preimage,
deposit_id: details.deposit_id,
deposit_request: details.deposit_request,
funding_hash: details.fund_id,
funding_payment: details.fund_payment,
request: details.fund_request,
refund_public_key: details.remote_public_key,
timeout: details.timeout,
});
} catch (err) {
return cbk([400, 'FailedToDecodeLoopResponseDetails', {err}]);
}
}
try {
// Decode the response
const decoded = decodeOffToOnResponse({
network: getNetwork.bitcoinjs,
response: args.response,
});
return cbk(null, {
coop_private_key_hash: decoded.coop_private_key_hash,
coop_public_key: decoded.coop_public_key,
deposit_id: decoded.coop_private_key_hash,
deposit_mtokens: decoded.deposit_mtokens,
deposit_payment: decoded.deposit_payment,
incoming_peer: decoded.incoming_peer,
push: decoded.push,
refund_public_key: decoded.refund_public_key,
request: decoded.request,
timeout: decoded.timeout,
});
} catch (err) {
return cbk([400, 'FailedToDecodeResponseDetails', {err}]);
}
}],
// Get the deposit payment details
getDepositPayment: ['responseDetails', ({responseDetails}, cbk) => {
const id = responseDetails.coop_private_key_hash;
const request = responseDetails.deposit_request;
return getPayment({
id: id || parsePaymentRequest({request}).id,
lnd: args.lnd,
},
(err, res) => {
// Ignore payment not found errors
if (isArray(err) && err.slice().shift() === 404) {
return cbk(null, {});
}
return cbk(err, res);
});
}],
// Get the funding payment details
getFundPayment: ['responseDetails', ({responseDetails}, cbk) => {
return getPayment({
id: parsePaymentRequest({request: responseDetails.request}).id,
lnd: args.lnd,
},
(err, res) => {
// Ignore payment not found errors
if (isArray(err) && err.slice().shift() === 404) {
return cbk(null, {});
}
return cbk(err, res);
});
}],
// Check that the swap id matches the request
checkSwapId: [
'requestDetails',
'responseDetails',
({requestDetails, responseDetails}, cbk) =>
{
const request = responseDetails.request;
const swapId = sha256(hexAsBuffer(requestDetails.secret));
if (parsePaymentRequest({request}).id !== swapId) {
return cbk([400, 'IncorrectSwapResponseForSwapRequest']);
}
return cbk();
}],
// Initiate the Loop service connection for swap cosigning and releases
loopService: [
'getNetwork',
'responseDetails',
({getNetwork, responseDetails}, cbk) =>
{
// Exit early when not using the Lightning Loop service
if (!args.is_loop_service) {
return cbk();
}
const {metadata} = lightningLabsSwapAuth({
macaroon: responseDetails.auth_macaroon,
preimage: responseDetails.auth_preimage,
});
const {service} = lightningLabsSwapService({
network: getNetwork.network,
});
return cbk(null, {metadata, service});
}],
// Get the claim public key
getClaimKey: ['ecp', 'requestDetails', ({ecp, requestDetails}, cbk) => {
// Exit early when a private key is defined
if (!!requestDetails.solo_private_key) {
const privateKey = hexAsBuffer(requestDetails.solo_private_key);
const {publicKey} = ecp.fromPrivateKey(privateKey);
return cbk(null, {public_key: bufferAsHex(publicKey)});
}
return getPublicKey({
family,
index: requestDetails.key_index,
lnd: args.lnd,
},
cbk);
}],
// Pay the funding request that is locked to the swap hash
payToFund: [
'checkSwapId',
'getFundPayment',
'responseDetails',
asyncReflect(({getFundPayment, responseDetails}, cbk) =>
{
// Exit early when the payment is pending
if (!!getFundPayment.payment || !!getFundPayment.pending) {
return cbk();
}
const request = responseDetails.request;
// Exit early when not paying to fund in-flow
if (!!args.is_external_funding) {
args.emitter.emit('update', {
external_funding_pay_to_fund_swap_offchain: request,
must_be_in_through: responseDetails.incoming_peer || undefined,
});
return cbk();
}
args.emitter.emit('update', {funding_swap_offchain: request});
return pay({
request,
incoming_peer: responseDetails.incoming_peer || undefined,
lnd: args.lnd,
max_fee: args.max_fee_funding,
},
cbk);
})],
// Cancel the swap when funding fails to release the deposit hold
cancelSwap: [
'loopService',
'payToFund',
'responseDetails',
({loopService, payToFund, responseDetails}, cbk) =>
{
// Exit early when not using Lightning Loop and no way to cancel swap
if (!args.is_loop_service) {
return cbk(payToFund.error);
}
// Exit early when there is no need to cancel the swap
if (!payToFund.error) {
return cbk();
}
const fund = parsePaymentRequest({request: responseDetails.request});
return cancelSwapOut({
id: fund.id,
metadata: loopService.metadata,
payment: fund.payment,
service: loopService.service,
},
err => {
if (!!err) {
return cbk(err);
}
return cbk(payToFund.error);
});
}],
// Calculate the deadline for the swap on-chain HTLC to confirm
deadline: ['responseDetails', ({responseDetails}, cbk) => {
return cbk(null, {
max_reveal_height: responseDetails.timeout - defaultMinSweepBlocks,
remaining_ms: defaultWaitForChainFundingMs,
});
}],
// Calculate a starting height for the swap to look for the HTLC output
startHeight: [
'getHeight',
'responseDetails',
({getHeight, responseDetails}, cbk) =>
{
// Start looking for the on-chain HTLC at around request creation date
const {request} = responseDetails;
const createdAt = new Date(parsePaymentRequest({request}).created_at);
const blocksSinceRequest = ceil((new Date() - createdAt) / minBlockMs);
return cbk(null, getHeight.current_block_height - blocksSinceRequest);
}],
// Derive the swap script branches
branches: [
'ecp',
'getClaimKey',
'requestDetails',
'responseDetails',
({ecp, getClaimKey, requestDetails, responseDetails}, cbk) =>
{
const swapScript = swapScriptBranches({
ecp,
claim_public_key: getClaimKey.public_key,
hash: sha256(hexAsBuffer(requestDetails.secret)),
refund_public_key: responseDetails.refund_public_key,
timeout: responseDetails.timeout,
});
return cbk(null, {
branches: swapScript.branches,
claim: swapScript.claim,
hash: hashForTree({branches: swapScript.branches}).hash,
timeout: responseDetails.timeout,
});
}],
// Derive the output script
joined: [
'ecp',
'branches',
'getClaimKey',
'requestDetails',
'responseDetails',
({ecp, branches, getClaimKey, requestDetails, responseDetails}, cbk) =>
{
// Exit early when using MuSig2 with Lightning Loop
if (!!args.is_loop_service) {
return beginGroupSigningSession({
lnd: args.lnd,
key_family: family,
key_index: requestDetails.key_index,
public_keys: [
getClaimKey.public_key,
responseDetails.refund_public_key,
],
root_hash: branches.hash,
},
cbk);
}
const privateKey = requestDetails.coop_private_key;
const jointPublicKey = pointAdd(
ecp.fromPrivateKey(hexAsBuffer(privateKey)).publicKey,
hexAsBuffer(responseDetails.coop_public_key)
);
const output = v1OutputScript({
hash: branches.hash,
internal_key: bufferAsHex(from(jointPublicKey)),
});
return cbk(null, {
external_key: output.external_key,
internal_key: pubKeyAsInternalKey(jointPublicKey),
output_script: output.script,
});
}],
// Overall swap details
swap: ['branches', 'joined', ({branches, joined}, cbk) => {
// Exit early when using Lightning Loop swap service
if (!!args.is_loop_service) {
const output = v1OutputScript({
hash: branches.hash,
internal_key: joined.internal_key,
});
return cbk(null, {
claim_script: branches.claim,
external_key: output.external_key,
internal_key: joined.internal_key,
output_script: externalKeyAsOutputScript(joined.external_key),
script_branches: branches.branches,
timeout: branches.timeout,
});
}
return cbk(null, {
claim_script: branches.claim,
external_key: joined.external_key,
internal_key: joined.internal_key,
output_script: joined.output_script,
script_branches: branches.branches,
timeout: branches.timeout,
});
}],
// Lock funds off-chain to the deposit
payToDeposit: [
'checkSwapId',
'deadline',
'ecp',
'getDepositPayment',
'getNetwork',
'requestDetails',
'responseDetails',
'startHeight',
'swap',
({
deadline,
ecp,
getNetwork,
getDepositPayment,
requestDetails,
responseDetails,
startHeight,
swap,
},
cbk) =>
{
// Exit early when deposit payment is already existing
if (!!getDepositPayment.payment || !!getDepositPayment.pending) {
return cbk();
}
const request = responseDetails.deposit_request;
if (!!args.is_external_funding && !!args.is_loop_service) {
args.emitter.emit('update', {external_pay_deposit_request: request});
return cbk();
}
// Exit early when using the Lightning Loop service
if (!!args.is_loop_service) {
return pay({
request,
max_fee: args.max_fee_deposit,
lnd: args.lnd,
},
err => {
if (!!err) {
return cbk([503, 'UnexpectedErrorOnDepositPayment', {err}]);
}
return cbk();
});
}
return asyncRace([
// Don't bother waiting for the deposit to be taken
cbk => {
// Exit early when using explorer API
if (!!args.request) {
return findDeposit({
after: startHeight,
confirmations: args.min_confirmations || defaultConfsCount,
network: getNetwork.network,
output_script: swap.output_script,
poll_interval_ms: pollInterval[getNetwork.network],
request: args.request,
timeout: deadline.remaining_ms,
tokens: requestDetails.tokens,
},
cbk);
}
// Look for the HTLC output on chain
return findConfirmedOutput({
lnd: args.lnd,
min_confirmations: args.min_confirmations || defaultConfsCount,
output_script: swap.output_script,
start_height: startHeight,
timeout_ms: deadline.remaining_ms,
tokens: requestDetails.tokens,
},
cbk);
},
// Pay to the deposit invoice
cbk => {
// The deposit will include the cooperative key for top level key
const privateKey = hexAsBuffer(requestDetails.coop_private_key);
const id = responseDetails.coop_private_key_hash;
const {request} = responseDetails;
const to = parsePaymentRequest({request});
args.emitter.emit('update', {paying_execution: id});
return payViaPaymentDetails({
id,
cltv_delta: to.cltv_delta,
destination: to.destination,
features: to.features,
incoming_peer: requestDetails.incoming_peer || undefined,
lnd: args.lnd,
max_fee: args.max_fee_deposit,
messages: [{
type: typePayMetadata,
value: bufferAsHex(ecp.fromPrivateKey(privateKey).publicKey),
}],
mtokens: responseDetails.deposit_mtokens,
payment: responseDetails.deposit_payment,
routes: to.routes,
},
err => {
// Exit early when there is no error paying the deposit
if (!err) {
return cbk();
}
const [, message] = err;
// Exit early and ignore rejections from the destination
if (message === 'PaymentRejectedByDestination') {
return cbk();
}
return cbk(err);
});
},
],
cbk);
}],
// Find the output on chain
findOutput: [
'deadline',
'getNetwork',
'requestDetails',
'startHeight',
'swap',
({deadline, getNetwork, requestDetails, startHeight, swap}, cbk) =>
{
const [, key] = decompileOutputScript(swap.output_script);
const outputScript = swap.output_script;
const prefix = networks[getNetwork.bitcoinjs].bech32;
const address = encodeAddress(prefix, v1AddressWords(key));
args.emitter.emit('update', {
waiting_for_chain_funding: address,
required_confirmations: args.min_confirmations || defaultConfsCount,
});
if (!!args.request) {
return findDeposit({
after: startHeight,
confirmations: args.min_confirmations || defaultConfsCount,
network: getNetwork.network,
output_script: outputScript,
poll_interval_ms: pollInterval[getNetwork.network],
request: args.request,
timeout: deadline.remaining_ms,
tokens: requestDetails.tokens,
},
cbk);
}
return findConfirmedOutput({
lnd: args.lnd,
min_confirmations: args.min_confirmations || defaultConfsCount,
output_script: outputScript,
start_height: startHeight,
timeout_ms: deadline.remaining_ms,
tokens: requestDetails.tokens,
},
(err, res) => {
if (!!err) {
return cbk(err);
}
return cbk(null, {
confirm_height: res.confirmation_height,
transaction_id: res.transaction_id,
transaction_vout: res.transaction_vout,
});
});
}],
// Check the found output to make sure it's valid for the swap
checkOutput: [
'deadline',
'findOutput',
({deadline, findOutput}, cbk) =>
{
args.emitter.emit('update', findOutput);
// Make sure the HTLC isn't funded from a cb output to avoid maturity
if (findOutput.is_coinbase) {
return cbk([501, 'CoinbaseSwapFundingUnsupported']);
}
// Make sure the confirmation height is before the deadline
if (findOutput.confirm_height >= deadline.max_reveal_height) {
return cbk([503, 'SwapConfirmedTooLateToCompleteOffChain']);
}
return cbk();
}],
// Generate claim transactions
claimTxs: [
'checkOutput',
'createAddress',
'deadline',
'ecp',
'findOutput',
'getFeeRate',
'getNetwork',
'requestDetails',
'swap',
({
createAddress,
deadline,
ecp,
findOutput,
getFeeRate,
getNetwork,
requestDetails,
swap,
},
cbk) =>
{
const period = deadline.max_reveal_height - findOutput.confirm_height;
const rates = {};
const startRate = getFeeRate.tokens_per_vbyte;
const multiplier = maxClaimMultiple(startRate, requestDetails.tokens);
const feeRates = times(period).map(blocks => {
const {rate} = confirmationFee({
multiplier,
before: deadline.max_reveal_height - findOutput.confirm_height,
cursor: blocks,
fee: startRate,
});
const feeRate = floor(rate);
return {rate: feeRate, height: findOutput.confirm_height + blocks};
});
const claims = feeRates.map(({rate, height}) => {
const {transaction} = taprootClaimTransaction({
ecp,
block_height: height - fuzzTimelock,
claim_script: swap.claim_script,
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: requestDetails.solo_private_key,
script_branches: swap.script_branches,
secret: requestDetails.secret,
sends: [],
sweep_address: createAddress.address,
tokens: requestDetails.tokens,
transaction_id: findOutput.transaction_id,
transaction_vout: findOutput.transaction_vout,
});
if (!!rates[rate]) {
return;
}
rates[rate] = true;
return {height, transaction};
});
return cbk(null, claims.filter(n => !!n));
}],
// Sign claim transactions
signClaims: [
'claimTxs',
'requestDetails',
'swap',
({claimTxs, requestDetails, swap}, cbk) =>
{
// Exit early when using a direct private key
if (!!requestDetails.solo_private_key) {
return cbk(null, claimTxs);
}
args.emitter.emit('update', {signing_unilateral_claim_tx: true});
return asyncMap(claimTxs, ({height, transaction}, cbk) => {
return signTransaction({
transaction,
inputs: [{
sighash,
key_family: family,
key_index: requestDetails.key_index,
output_script: swap.output_script,
output_tokens: requestDetails.tokens,
vin: sweepInputIndex,
witness_script: swap.claim_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.claim_script,
script_branches: swap.script_branches,
});
const witness = [
requestDetails.secret,
signature,
swap.claim_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, {height, transaction: tx.toHex()});
});
},
cbk);
}],
// Push the preimage to allow for a cooperative swap
pushPreimage: [
'ecp',
'findOutput',
'loopService',
'requestDetails',
'responseDetails',
'signClaims',
asyncReflect(({
ecp,
loopService,
requestDetails,
responseDetails,
signClaims,
},
cbk) =>
{
const [{transaction}] = signClaims;
args.emitter.emit('update', {unilateral_claim_tx: transaction});
// Exit early when pushing the preimage to the Lightning Loop service
if (!!args.is_loop_service) {
args.emitter.emit('update', {releasing_funds: true});
return pushSecret({
is_taproot: true,
metadata: loopService.metadata,
secret: requestDetails.secret,
service: loopService.service,
},
err => {
if (!!err) {
return cbk([503, 'UnexpectedErrorPushingSwapSecret', {err}]);
}
return cbk();
});
}
// Exit early when performing an uncooperative spend swap
if (!!args.is_uncooperative) {
return cbk();
}
const privateKey = requestDetails.coop_private_key;
const to = parsePaymentRequest({request: responseDetails.request});
const pubKey = ecp.fromPrivateKey(hexAsBuffer(privateKey)).publicKey;
const id = sha256(pubKey);
args.emitter.emit('update', {requesting_cooperative_swap: id});
return payViaPaymentDetails({
id,
cltv_delta: to.cltv_delta,
destination: to.destination,
features: to.features,
lnd: args.lnd,
max_fee: defaultMaxPreimagePushFee,
payment: responseDetails.push,
routes: to.routes,
tokens: tokensForPushPreimage,
messages: [{type: typePayMetadata, value: requestDetails.secret}],
},
err => {
const [, message] = err;
if (message !== messageRejected) {
return cbk([503, 'ExpectedRejectedPaymentFromDestination']);
}
args.emitter.emit('update', {coop_request_received: to.destination});
return cbk();
});
})],
// Get the cooperative key from the settled deposit payment
findCoopKey: [
'pushPreimage',
'responseDetails',
asyncReflect(({responseDetails}, cbk) =>
{
// Exit early when not needing the coop key for Lightning Loop request
if (!!args.is_loop_service) {
return cbk();
}
// Exit early not pursuing a cooperative swap flow
if (!!args.is_avoiding_broadcast || !!args.is_uncooperative) {
return cbk();
}
const id = responseDetails.coop_private_key_hash;
// Listen for the preimage to arrive from the deposit
const sub = subscribeToPastPayment({id, lnd: args.lnd});
// The deposit is expected to resolve after the preimage is pushed
const timeout = setTimeout(() => {
sub.removeAllListeners();
return cbk([503, 'TimedOutWaitingForCooperativeKey']);
},
defaultDepositSettleTimeoutMs);
const done = (err, res) => {
clearTimeout(timeout);
sub.removeAllListeners();
return cbk(err, res);
};
// Wait for the deposit to clear
sub.on('confirmed', ({secret}) => done(null, secret));
sub.on('error', err => {
return done([503, 'FailedToFindDepositPayment', {err}]);
});
return;
})],
// Generate cooperative sweep transactions
coopTx: [
'branches',
'createAddress',
'deadline',
'ecp',
'findCoopKey',
'findOutput',
'getClaimKey',
'getFeeRate',
'getNetwork',
'loopService',
'payToDeposit',
'requestDetails',
'responseDetails',
'swap',
asyncReflect(({
branches,
createAddress,
deadline,
ecp,
findCoopKey,
findOutput,
getClaimKey,
getFeeRate,
getNetwork,
loopService,
requestDetails,
responseDetails,
swap,
},
cbk) =>
{
// Exit early when not expecting a cooperative sweep
if (!!args.is_avoiding_broadcast || !!args.is_uncooperative) {
return cbk();
}
const period = deadline.max_reveal_height - findOutput.confirm_height;
const rates = {};
const startRate = getFeeRate.tokens_per_vbyte;
const multiplier = maxCoopMultiple(startRate, requestDetails.tokens);
const feeRates = times(period).map(blocks => {
const {rate} = confirmationFee({
multiplier,
before: deadline.max_reveal_height - findOutput.confirm_height,
cursor: blocks,
fee: startRate,
});
return {
height: findOutput.confirm_height + blocks,
rate: floor(rate),
};
});
// Exit early when using MuSig2 with Loop service
if (!!args.is_loop_service) {
args.emitter.emit('update', {requesting_coop_signatures: true});
return asyncMapSeries(feeRates, ({height, rate}, cbk) => {
// Exit early when this rate is already signed for
if (!!rates[rate]) {
return cbk();
}
// Skip signing for this rate in the future
rates[rate] = true;
return getLoopCoopSignedTransaction({
fee_tokens_per_vbyte: rate,
funding_hash: responseDetails.funding_hash,
funding_payment: responseDetails.funding_payment,
key_family: family,
key_index: requestDetails.key_index,
lnd: args.lnd,
metadata: loopService.metadata,
network: getNetwork.network,
output_script: swap.output_script,
public_keys: [
getClaimKey.public_key,
responseDetails.refund_public_key,
],
root_hash: branches.hash,
script_branches: swap.script_branches,
service: loopService.service,
sweep_address: createAddress.address,
tokens: requestDetails.tokens,
transaction_id: findOutput.transaction_id,
transaction_vout: findOutput.transaction_vout,
},
(err, res) => {
if (!!err) {
return cbk([503, 'UnexpecctedErrorGettingCoopSign', {err}]);
}
return cbk(null, {height, transaction: res.transaction});
});
},
(err, res) => {
if (!!err) {
return cbk(err);
}
return cbk(null, res.filter(n => !!n));
});
}
if (!findCoopKey.value) {
return cbk([503, 'FailedToFindCoopKey']);
}
const partnerKey = findCoopKey.value;
const privateKeysHex = [requestDetails.coop_private_key, partnerKey];
const privateKeys = privateKeysHex.map(hexAsBuffer);
const coopKey = ecp.fromPrivateKey(from(privateAdd(...privateKeys)));
const coopPrivateKey = hexAsBuffer(requestDetails.coop_private_key);
const jointPublicKey = pointAdd(
ecp.fromPrivateKey(coopPrivateKey).publicKey,
hexAsBuffer(responseDetails.coop_public_key)
);
// The public key from the private keys should be the combined pubkeys
if (!coopKey.publicKey.equals(jointPublicKey)) {
return cbk([503, 'ReceivedIncorrectPrivateCoopKey']);
}
// Derive sweep transactions at different fee rates
const coop = feeRates.map(({height, rate}) => {
const {transaction} = taprootCoopTransaction({
ecp,
fee_tokens_per_vbyte: rate,
network: getNetwork.network,
output_script: swap.output_script,
private_keys: privateKeysHex,
script_branches: swap.script_branches,
sweep_address: createAddress.address,
tokens: requestDetails.tokens,
transaction_id: findOutput.transaction_id,
transaction_vout: findOutput.transaction_vout,
});
return {height, transaction};
});
const [{transaction}] = coop;
args.emitter.emit('update', {cooperative_claim_tx: transaction});
return cbk(null, coop);
})],
// Sweeps to publish
sweeps: [
'coopTx',
'findOutput',
'signClaims',
'swap',
({coopTx, findOutput, signClaims, swap}, cbk) =>
{
return cbk(null, {
confirm_height: findOutput.confirm_height,
output_script: swap.output_script,
transactions: coopTx.value || signClaims,
transaction_id: findOutput.transaction_id,
transaction_vout: findOutput.transaction_vout,
});
}],
// Publish the sweeps
publish: [
'findOutput',
'getNetwork',
'requestDetails',
'swap',
'sweeps',
({findOutput, getNetwork, requestDetails, swap, sweeps}, cbk) =>
{
// Exit early when avoiding broadcast of the sweep
if (!!args.is_avoiding_broadcast) {
return cbk();
}
const subBlocks = subscribeToBlocks({lnd: args.lnd});
const subSpend = subscribeToSpend({
delay_ms: pollInterval[getNetwork.network],
lnd: args.lnd,
min_height: findOutput.confirm_height - fuzzBlocks,
network: getNetwork.network,
output_script: swap.output_script,
request: args.request,
transaction_id: findOutput.transaction_id,
transaction_vout: findOutput.transaction_vout,
});
const done = (err, res) => {
subBlocks.removeAllListeners();
subSpend.removeAllListeners();
return cbk(err, res);
};
const [start] = sweeps.transactions;
// Broadcast sweeps into a block
subBlocks.on('block', block => {
const broadcast = sweeps.transactions.filter(sweep => {
return sweep.height <= block.height;
});
if (!broadcast.length) {
return;
}
const [{transaction}] = broadcast.reverse();
const tx = fromHex(transaction);
const fee = requestDetails.tokens - sumOf(tx.outs.map(n => n.value));
args.emitter.emit('update', {
blocks_until_potential_funds_forfeit: swap.timeout - block.height,
broadcasting_fee_rate: fee / tx.virtualSize(),
broadcasting_tx_to_resolve_swap: transaction,
broadcasting_tx_id: tx.getId(),
});
return broadcastChainTransaction({
transaction,
lnd: args.lnd,
},
err => {});
});
subBlocks.on('error', err => done(err));
// Look for the sweep to confirm
subSpend.on('confirmation', ({transaction}) => {
const tx = fromHex(transaction);
const fee = requestDetails.tokens - sumOf(tx.outs.map(n => n.value));
return done(null, {fee: fee, rate: fee / tx.virtualSize()});
});
subSpend.on('error', err => done(err));
return;
}],
// Get the funding payment if a funding payment was made
getFunding: ['responseDetails', 'publish', ({responseDetails}, cbk) => {
const {id} = parsePaymentRequest({request: responseDetails.request});
return getPayment({id, lnd: args.lnd}, cbk);
}],
// Get the execution payment to see what was paid and what fee
getDeposit: ['responseDetails', 'publish', ({responseDetails}, cbk) => {
return getPayment({
id: responseDetails.deposit_id,
lnd: args.lnd,
},
cbk);
}],
// Summarize swap
summary: [
'getFunding',
'getDeposit',
'publish',
'requestDetails',
({getFunding, getDeposit, publish, requestDetails}, cbk) =>
{
const deposit = getDeposit.payment || {};
const funding = getFunding.payment || {};
const paid = [deposit.tokens, funding.tokens].filter(n => !!n);
const routing = [deposit.fee, funding.fee].filter(n => !!n);
const destinationFee = sumOf(paid) - requestDetails.tokens;
const allFees = destinationFee + sumOf(routing) + publish.fee;
return cbk(null, {
paid_chain_fee: publish.fee,
paid_execution_routing_fee: deposit.fee || undefined,
paid_funding_routing_fee: funding.fee || undefined,
paid_swap_fee: destinationFee || undefined,
total_fee: allFees,
total_fee_rate: ppmRate(allFees, requestDetails.tokens),
transaction_fee_rate: publish.rate,
});
}],
},
returnResult({reject, resolve, of: 'summary'}, cbk));
});
};