paid-services
Version:
Lightning Paid Services library
338 lines (292 loc) • 9.92 kB
JavaScript
const asyncAuto = require('async/auto');
const {getChainBalance} = require('ln-service');
const {getChannels} = require('ln-service');
const {getNetwork} = require('ln-sync');
const {getWalletInfo} = require('ln-service');
const {returnResult} = require('asyncjs-util');
const convertFiat = require('./convert_fiat');
const createAnchoredTrade = require('./create_anchored_trade');
const serviceOpenTrade = require('./service_open_trade');
const asNumber = n => parseFloat(n, 10);
const daysAsMs = days => Number(days) * 1000 * 60 * 60 * 24;
const defaultChannelSize = 5000000;
const defaultExpirationDays = 1;
const {floor} = Math;
const futureDate = ms => new Date(Date.now() + ms).toISOString();
const isNumber = n => !isNaN(n);
const {min} = Math;
const minChannelSize = 20000;
const ppmCost = (amount, rate) => Math.ceil(amount * rate / 1000000);
const priceFiat = 'price-in-fiat';
const pricePpm = 'price-in-ppm';
const priceTokens = 'price-in-tokens';
const query = 'How much would you like to sell?';
const usdAsPrice = usd => `${usd}*USD`;
/** Create a new channel sale
{
ask: <Ask Function>
lnd: <Authenticated LND API Object>
logger: <Winston Logger Object>
request: <Request Function>
}
@returns via cbk or Promise
*/
module.exports = ({ask, lnd, logger, request}, cbk) => {
return new Promise((resolve, reject) => {
return asyncAuto({
// Check arguments
validate: cbk => {
if (!ask) {
return cbk([400, 'ExpectedAskFunctionToCreateChannelSale']);
}
if (!lnd) {
return cbk([400, 'ExpectedLndToCreateChannelSale']);
}
if (!logger) {
return cbk([400, 'ExpectedLoggerToCreateChannelSale']);
}
if (!request) {
return cbk([400, 'ExpectedRequestFunctionToCreateChannelSale']);
}
return cbk();
},
// Get the current chain balance to set the limit for a channel sale
getBalance: ['validate', ({}, cbk) => getChainBalance({lnd}, cbk)],
// Get the public channels to use for an open trade
getChannels: ['validate', ({}, cbk) => {
return getChannels({lnd, is_public: true}, cbk);
}],
// Get self identity, including alias
getIdentity: ['validate', ({}, cbk) => getWalletInfo({lnd}, cbk)],
// Get the network name to use for an open trade
getNetwork: ['validate', ({}, cbk) => getNetwork({lnd}, cbk)],
// Ask for channel capacity to sell
askForAmount: ['getBalance', ({getBalance}, cbk) => {
if (getBalance.chain_balance < minChannelSize) {
return cbk([400, 'InsufficientOnChainFundsToSellChannel']);
}
return ask({
default: min(defaultChannelSize, getBalance.chain_balance),
message: `${query} (Available balance: ${getBalance.chain_balance})`,
name: 'amount',
type: 'input',
validate: input => {
if (!input) {
return false;
}
// The token amount should be numeric
if (!isNumber(input)) {
return 'Expected numeric amount for channel capacity';
}
// Avoid listing more coins than are present on-chain
if (Number(input) > getBalance.chain_balance) {
return 'Capacity cannot be more than available balance';
}
if (Number(input) < minChannelSize) {
return `Channel capacity must be larger than ${minChannelSize}`;
}
// Disallow fractional values
if (!!isNumber(input) && floor(input) !== asNumber(input)) {
return 'Decimal capacities are not supported';
}
return true;
},
},
res => cbk(null, res));
}],
// Select method of pricing
selectPricing: ['askForAmount', ({}, cbk) => {
return ask({
choices: [
{name: 'Set absolute price', value: priceTokens},
{name: 'Set price by PPM (parts per million)', value: pricePpm},
{name: 'Set price in fiat (USD)', value: priceFiat},
],
default: pricePpm,
message: '?',
name: 'action',
type: 'list',
},
res => cbk(null, res));
}],
// Ask for fiat based cost
askForFiat: ['selectPricing', ({selectPricing}, cbk) => {
// Exit early when the pricing is not in fiat
if (selectPricing.action !== priceFiat) {
return cbk();
}
return ask({
default: '0.01',
message: 'Dollars USD to charge?',
name: 'usd',
type: 'input',
validate: input => {
if (!input) {
return false;
}
// Price of sale should be numeric
if (!isNumber(input)) {
return 'Not a number of dollars, try a number?';
}
return true;
},
},
res => cbk(null, res));
}],
// Ask for absolute cost
askForPrice: ['selectPricing', ({selectPricing}, cbk) => {
// Exit early when the pricing is not absolute
if (selectPricing.action !== priceTokens) {
return cbk();
}
return ask({
default: '1337',
message: 'Amount to charge?',
name: 'tokens',
type: 'input',
validate: input => {
if (!input) {
return false;
}
// Price of sale should be numeric
if (!isNumber(input)) {
return 'Not a number, try a number?';
}
// Disallow fractional values
if (!!isNumber(input) && floor(input) !== asNumber(input)) {
return 'Specified precision not supported';
}
return true;
},
},
res => cbk(null, res));
}],
// Ask for rate-based cost
askForRate: ['askForAmount', 'selectPricing', ({selectPricing}, cbk) => {
// Exit early when the pricing is not in ppm
if (selectPricing.action !== pricePpm) {
return cbk();
}
return ask({
default: '350',
message: 'Price in PPM?',
name: 'rate',
type: 'input',
validate: input => {
if (!input) {
return false;
}
// Price of sale should be in numeric ppm
if (!isNumber(input)) {
return 'Unable to parse rate, try a numeric ppm rate?';
}
// Disallow fractional values
if (!!isNumber(input) && floor(input) !== asNumber(input)) {
return 'Specified precision not supported';
}
return true;
},
},
res => cbk(null, res));
}],
// Ask for the expiration of the channel sale
askForDays: ['askForFiat', 'askForPrice', 'askForRate', ({}, cbk) => {
return ask({
default: defaultExpirationDays,
name: 'days',
message: 'Days to offer this for?',
validate: input => {
if (!isNumber(input) || !Number(input)) {
return false;
}
return true;
},
},
res => cbk(null, res));
}],
// Check ability to get fiat prices
checkCostLookup: ['askForFiat', ({askForFiat}, cbk) => {
// Exit early when no fiat is referenced
if (!askForFiat) {
return cbk();
}
return convertFiat({request, price: usdAsPrice(askForFiat.usd)}, cbk);
}],
// Determine the tokens value of the trade
cost: [
'askForAmount',
'askForFiat',
'askForPrice',
'askForRate',
({askForAmount, askForFiat, askForPrice, askForRate}, cbk) =>
{
// Exit early when price is in fiat, not tokens
if (!!askForFiat) {
return cbk(null, {price: usdAsPrice(askForFiat.usd)});
}
// Exit early when the price is specified directly
if (!!askForPrice) {
return cbk(null, {tokens: askForPrice.tokens});
}
// Calculate the rate that was specified in PPM
const tokens = ppmCost(Number(askForAmount.amount), askForRate.rate);
return cbk(null, {tokens});
}],
// Create an anchor invoice for the channel sale
createAnchor: [
'askForAmount',
'askForDays',
'checkCostLookup',
'cost',
({askForAmount, askForDays, askForRate, cost}, cbk) =>
{
return createAnchoredTrade({
lnd,
channel: Number(askForAmount.amount),
expires_at: futureDate(daysAsMs(askForDays.days)),
price: cost.price,
tokens: cost.tokens,
},
cbk);
}],
// Wait for a peer to connect and ask for the channel sale details
serviceSaleRequests: [
'askForAmount',
'askForDays',
'cost',
'createAnchor',
'getChannels',
'getNetwork',
'getIdentity',
({
askForAmount,
askForDays,
askForRate,
cost,
createAnchor,
getChannels,
getNetwork,
getIdentity,
},
cbk) =>
{
return serviceOpenTrade({
lnd,
logger,
request,
channel: Number(askForAmount.amount),
channels: getChannels.channels,
expires_at: futureDate(daysAsMs(askForDays.days)),
id: createAnchor.id,
network: getNetwork.network,
price: cost.price,
public_key: getIdentity.public_key,
tokens: cost.tokens,
uris: (getIdentity.uris || []),
},
cbk);
}],
},
returnResult({reject, resolve}, cbk));
});
};