paid-services
Version:
Lightning Paid Services library
416 lines (328 loc) • 12.4 kB
JavaScript
const EventEmitter = require('events');
const {randomBytes} = require('crypto');
const {assembleUnsignedPsbt} = require('ln-sync');
const {decodeGroupDetails} = require('./../messages');
const {decodePendingProposal} = require('./../messages');
const {decodeSignedFunding} = require('./../messages');
const {encodeConnectedRecords} = require('./../messages');
const {encodeGroupDetails} = require('./../messages');
const {encodeSignedRecords} = require('./../messages');
const {encodeUnsignedFunding} = require('./../messages');
const {encodePartnersRecords} = require('./../messages');
const {partnersFromMembers} = require('./../members');
const {servicePeerRequests} = require('./../../p2p');
const {serviceTypeConfirmConnected} = require('./../../service_types');
const {serviceTypeFindGroupPartners} = require('./../../service_types');
const {serviceTypeGetGroupDetails} = require('./../../service_types');
const {serviceTypeRegisterPendingOpen} = require('./../../service_types');
const {serviceTypeRegisterSignedOpen} = require('./../../service_types');
const findRecord = (records, type) => records.find(n => n.type === type);
const {isArray} = Array;
const makeGroupId = () => randomBytes(16).toString('hex');
const minGroupCount = 2;
const now = () => new Date().toISOString();
const staleDate = () => new Date(Date.now() - (1000 * 60 * 10)).toISOString();
const typeGroupId = '1';
const uniq = arr => Array.from(new Set(arr));
/** Coordinate channel group
{
capacity: <Channel Capacity Tokens Number>
count: <Group Members Count Number>
ecp: <ECPair Library Object>
identity: <Coordinator Identity Public Key Hex String>
lnd: <Authenticated LND API Object>
[members]: [<Member Node Id Public Key Hex String>]
rate: <Chain Fee Rate Number>
}
@returns
{
events: <Event Emitter Object>
id: <Group Id Hex String>
connected: <Add Connected Identity Public Key Hex String>
partners: <Get Channel Partners Function>
proposed: <Add Proposed Channel Function>
sign: <Add Signed Channel Function>
signed: <Get Signed PSBTs Function>
unsigned: <Get Unsigned PSBT Function>
}
// All members are connected
@event 'connected'
// A member was connected to their peers
@event 'connecting'
{
id: <Connected Member Public Key Hex String>
}
// All members have funded with their outbound peers
@event 'funded'
// A member has funded with their outbound peer
@event 'funding'
{
id: <Funding Member Public Key Hex String>
}
// A new member joined
@event 'joining'
{
id: <Joined Member Public Key Hex String>
}
// All members are joined
@event 'joined'
{
ids: [<Joined Member Public Key Hex String>]
}
// A member signaled they were present
@event 'present'
{
id: <Present Member Public Key Hex String>
}
// A member submitted their partial signature
@event 'signing'
{
id: <Member Public Key Hex String>
}
// All members have submitted their partial signatures
@event 'signed'
*/
module.exports = ({capacity, count, ecp, identity, lnd, members, rate}) => {
if (count < minGroupCount) {
throw new Error('ExpectedHigherGroupMembersCountToCoordinateGroup');
}
if (!ecp) {
throw new Error('ExpectedEcpLibraryToCoordinateChannelGroup');
}
if (!identity) {
throw new Error('ExpectedSelfIdentityPublicKeyToCoordinateGroup');
}
if (!lnd) {
throw new Error('ExpectedAuthenticatedLndToCoordinateGroup');
}
if (!!members && uniq(members).length !== count) {
throw new Error('ExpectedCompleteSetOfAllowedMembers');
}
// Instantiate the group with self as a member
const group = {
allowed: members,
connected: [],
emitter: new EventEmitter(),
members: [{id: identity}],
proposed: [],
signed: [],
};
// The group has an identifier
const id = makeGroupId();
// Wait for peer requests
const service = servicePeerRequests({lnd});
// Listen for and respond to get group details requests
service.request({type: serviceTypeGetGroupDetails}, (req, res) => {
if (!isArray(req.records)) {
return res.failure([400, 'ExpectedArrayOfRecordsToGetGroupDetails']);
}
const idRecord = findRecord(req.records, typeGroupId);
if (!idRecord) {
return res.failure([400, 'ExpectedGroupIdRecordToGetGroupDetails']);
}
// Exit early when this request is for a different group
if (idRecord.value !== id) {
return;
}
return res.success(encodeGroupDetails({capacity, count, rate}));
});
// Listen for and respond to find member requests
service.request({type: serviceTypeFindGroupPartners}, (req, res) => {
if (!isArray(req.records)) {
return res.failure([400, 'ExpectedArrayOfRecordsToFindGroupPartners']);
}
const idRecord = findRecord(req.records, typeGroupId);
if (!idRecord) {
return res.failure([400, 'ExpectedGroupIdRecordToFindGroupPartners']);
}
// Exit early when this request is for a different group
if (idRecord.value !== id) {
return;
}
// Exit early when group member is not allowed
if (!!group.allowed && !group.allowed.includes(req.from)) {
return res.failure([403, 'AccessDeniedToGroup']);
}
// Emit event that someone is joining
if (!group.members.find(n => n.id === req.from)) {
group.emitter.emit('joining', {id: req.from});
}
// Refresh the member list, evicting members who stopped pinging
group.members = group.members
.filter(n => !n.last_joined || n.last_joined > staleDate())
.filter(n => n.id !== req.from);
// Exit early with failure when the group is full
if (group.members.length === count) {
return res.failure([503, 'GroupIsCurrentlyFull']);
}
// Add the group member details to the group
group.members.push({id: req.from, last_joined: new Date().toISOString()});
// Notify that the member is present
group.emitter.emit('present', {id: req.from});
// Exit with failure when a member dropped out after being locked in
if (!!group.ids && group.members.length < count) {
return res.failure([503, 'GroupMemberExited']);
}
// Exit early when still waiting for group to fill
if (group.members.length < count) {
return res.success({});
}
// Make sure members list is locked in
group.ids = group.ids || group.members.map(n => n.id);
// Emit event that everyone has joined
group.emitter.emit('joined', {ids: group.ids});
// Exit early when this is a pair group
if (count === minGroupCount) {
return res.success({});
}
// Derive position in members list
const {inbound, outbound} = partnersFromMembers({group, id: req.from});
// Encode partners for wire
const {records} = encodePartnersRecords({inbound, outbound});
return res.success({records});
});
// Listen for and respond to connected confirmations
service.request({type: serviceTypeConfirmConnected}, (req, res) => {
if (!isArray(req.records)) {
return res.failure([400, 'ExpectedArrayOfRecordsToConfirmConnection']);
}
const idRecord = findRecord(req.records, typeGroupId);
if (!idRecord) {
return res.failure([400, 'ExpectedGroupIdRecordToConfirmConnection']);
}
// Exit early when this request is for a different group
if (idRecord.value !== id) {
return;
}
// Group ids must be locked in before connections start
if (!group.ids) {
return res.failure([503, 'GroupMembersAreNotLockedInToStartConnects']);
}
// Cannot confirm connected when not part of the locked in group
if (!group.ids.includes(req.from)) {
return res.failure([403, 'MembershipNotPresentInLockedInMembers']);
}
// Emit event that someone is connecting
if (!group.connected.find(n => n.id === req.from)) {
group.emitter.emit('connecting', {id: req.from});
}
// Refresh the connected list, evicting remote members who stopped pinging
group.connected = group.connected
.filter(n => !n.last_connected || n.last_connected > staleDate())
.filter(n => n.id !== req.from);
// Add the group connection details to the group
group.connected.push({id: req.from, last_connected: now()});
const {records} = encodeConnectedRecords({count: group.connected.length});
// Emit event that all members are connected
if (group.connected.length === count) {
group.emitter.emit('connected', {});
}
return res.success({records});
});
// Listen for and respond to pending open registrations
service.request({type: serviceTypeRegisterPendingOpen}, (req, res) => {
if (!isArray(req.records)) {
return res.failure([400, 'ExpectedArrayOfRecordsToRegisterPendingOpen']);
}
const idRecord = findRecord(req.records, typeGroupId);
if (!idRecord) {
return res.failure([400, 'ExpectedGroupIdRecordToRegisterPendingOpen']);
}
// Exit early when this request is for a different group
if (idRecord.value !== id) {
return;
}
// Cannot register pending when not part of the locked in group
if (!group.ids.includes(req.from)) {
return res.failure([403, 'MembershipNotPresentInLockedInMembers']);
}
try {
decodePendingProposal({records: req.records});
} catch (err) {
return res.failure([400, err.message]);
}
const proposal = decodePendingProposal({records: req.records});
// Register the proposal
if (!group.proposed.find(n => n.id === req.from)) {
group.emitter.emit('funding', {id: req.from});
group.proposed.push({
change: proposal.change,
funding: [proposal.funding],
id: req.from,
utxos: proposal.utxos,
});
}
// Exit early when there are missing proposals
if (group.proposed.length !== group.ids.length) {
return res.success({});
}
const {failure, success} = res;
// Exit early when unsigned funding is already assembled
if (!!group.unsigned) {
return success(encodeUnsignedFunding({psbt: group.unsigned}));
}
// Put together the assembled PSBT
return assembleUnsignedPsbt({
capacity,
rate,
proposed: group.proposed,
},
(err, res) => {
if (!!err) {
return failure(err);
}
group.unsigned = res.psbt;
if (group.proposed.length === count) {
group.emitter.emit('funded', {});
}
const {records} = encodeUnsignedFunding({psbt: res.psbt});
return success({records});
});
});
// Listen for and respond to funding signatures
service.request({type: serviceTypeRegisterSignedOpen}, (req, res) => {
if (!isArray(req.records)) {
return res.failure([400, 'ExpectedArrayOfRecordsToRegisterSignedOpen']);
}
const idRecord = findRecord(req.records, typeGroupId);
if (!idRecord) {
return res.failure([400, 'ExpectedGroupIdRecordToRegisterSignedOpen']);
}
// Exit early when this request is for a different group
if (idRecord.value !== id) {
return;
}
// Cannot register signatures when not part of the locked in group
if (!group.ids.includes(req.from)) {
return res.failure([403, 'MembershipNotPresentInLockedInMembers']);
}
try {
decodeSignedFunding({ecp, records: req.records});
} catch (err) {
return res.failure([400, err.message]);
}
const signed = decodeSignedFunding({ecp, records: req.records});
// Register the signed funding
if (!group.signed.find(n => n.id === req.from)) {
// Emit event to indicate member has submitted their signed open
group.emitter.emit('signing', {id: req.from});
group.signed.push({id: req.from, signed: signed.psbt});
}
if (group.signed.length === count) {
group.emitter.emit('signed', {});
}
// Let the client know how many signatures are present
const {records} = encodeSignedRecords({count: group.signed.length});
return res.success({records});
});
return {
id,
connected: () => group.connected.push({id: identity}),
events: group.emitter,
partners: id => !!group.ids ? partnersFromMembers({group, id}) : undefined,
proposed: pending => group.proposed.push(pending),
sign: signed => group.signed.push(signed),
signed: () => group.signed,
unsigned: () => group.unsigned,
};
};