@sangaman/xud
Version:
Exchange Union Daemon
597 lines • 24.9 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const assert_1 = __importDefault(require("assert"));
const net_1 = __importDefault(require("net"));
const events_1 = require("events");
const Parser_1 = __importStar(require("./Parser"));
const packets = __importStar(require("./packets/types"));
const utils_1 = require("../utils/utils");
const packets_1 = require("./packets");
const errors_1 = __importDefault(require("./errors"));
const addressUtils_1 = __importDefault(require("../utils/addressUtils"));
const enums_1 = require("../types/enums");
const crypto_1 = require("crypto");
/** Represents a remote XU peer */
class Peer extends events_1.EventEmitter {
constructor(logger, pool) {
super();
this.logger = logger;
this.pool = pool;
this.connected = false;
this.opened = false;
this.parser = new Parser_1.default(packets_1.Packet.PROTOCOL_DELIMITER);
this.closed = false;
this.responseMap = new Map();
this.banScore = 0;
this.lastRecv = 0;
this.lastSend = 0;
/** A counter for packets sent to be used for assigning unique packet ids. */
this.packetCount = 0;
this.getStatus = () => {
let status;
if (this.connected) {
status = this.nodePubKey ?
`Connected to peer ${this.nodePubKey}` :
`Connected pre-handshake to peer ${addressUtils_1.default.toString(this.socketAddress)}`;
}
else {
status = 'Not connected';
}
return status;
};
/**
* Prepare a connection for use by ensuring it is active, exchanging [[HelloPacket]] with handshake data,
* and emit the `open` event if everything succeeds. Throw an error on unexpected handshake data.
* @param handshakeData our handshake data to send to the peer
* @param nodePubKey the expected nodePubKey of the node we are opening a connection with
*/
this.open = (handshakeData, nodePubKey) => __awaiter(this, void 0, void 0, function* () {
assert_1.default(!this.opened);
assert_1.default(!this.closed);
this.opened = true;
yield this.initConnection();
this.initStall();
yield this.initHello(handshakeData);
// TODO: Check that the peer's version is compatible with ours
if (nodePubKey && this.nodePubKey !== nodePubKey) {
this.close();
throw errors_1.default.UNEXPECTED_NODE_PUB_KEY(this.nodePubKey, nodePubKey, addressUtils_1.default.toString(this.socketAddress));
}
this.finalizeOpen();
// let the pool know that this peer is ready to go
this.emit('open');
});
/**
* Close a peer by ensuring the socket is destroyed and terminating all timers.
*/
this.close = () => {
if (this.closed) {
return;
}
this.closed = true;
this.connected = false;
if (this.socket) {
if (!this.socket.destroyed) {
this.socket.destroy();
}
delete this.socket;
}
if (this.pingTimer) {
clearInterval(this.pingTimer);
this.pingTimer = undefined;
}
if (this.stallTimer) {
clearInterval(this.stallTimer);
this.stallTimer = undefined;
}
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = undefined;
}
for (const [packetType, entry] of this.responseMap) {
this.responseMap.delete(packetType);
entry.reject(new Error('Peer was destroyed'));
}
this.emit('close');
};
this.sendPacket = (packet) => {
this.sendRaw(packet.toRaw());
this.packetCount += 1;
if (packet.direction === packets_1.PacketDirection.REQUEST) {
this.addResponseTimeout(packet.header.id, Peer.RESPONSE_TIMEOUT);
}
};
this.sendOrders = (orders, reqId) => {
const packet = new packets.OrdersPacket(orders, reqId);
this.sendPacket(packet);
};
this.sendNodes = (nodes, reqId) => {
const packet = new packets.NodesPacket(nodes, reqId);
this.sendPacket(packet);
};
this.sendRaw = (packetStr) => {
if (this.socket) {
this.socket.write(packetStr + packets_1.Packet.PROTOCOL_DELIMITER);
this.lastSend = Date.now();
}
};
this.increaseBan = (score) => {
this.banScore += score;
if (this.banScore >= 100) { // TODO: make configurable
this.logger.debug(`Ban threshold exceeded (${this.nodePubKey})`);
this.emit('ban');
return true;
}
return false;
};
/**
* Ensure we are connected (for inbound connections) or listen for the `connect` socket event (for outbound connections)
* and set the [[connectTime]] timestamp. If an outbound connection attempt errors or times out, throw an error.
*/
this.initConnection = () => {
assert_1.default(this.socket);
if (this.connected) {
assert_1.default(this.inbound);
this.connectTime = Date.now();
this.logger.debug(this.getStatus());
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const cleanup = () => {
if (this.connectTimeout) {
clearTimeout(this.connectTimeout);
this.connectTimeout = undefined;
}
if (this.socket) {
this.socket.removeListener('error', onError);
this.socket.removeListener('connect', onConnect);
}
};
const onError = (err) => {
cleanup();
reject(err);
};
const onConnect = () => {
this.connectTime = Date.now();
this.connected = true;
this.logger.debug(this.getStatus());
this.emit('connect');
cleanup();
resolve();
};
const onTimeout = () => {
cleanup();
reject(new Error('Connection timed out.'));
};
this.socket.once('connect', onConnect);
this.socket.once('error', onError);
this.connectTimeout = setTimeout(onTimeout, Peer.CONNECTION_TIMEOUT);
});
};
this.initStall = () => {
assert_1.default(!this.closed);
assert_1.default(!this.stallTimer);
this.stallTimer = setInterval(this.checkTimeout, Peer.STALL_INTERVAL);
};
this.initHello = (handshakeData) => __awaiter(this, void 0, void 0, function* () {
const packet = this.sendHello(handshakeData);
if (!this.handshakeState) {
// wait for an incoming HelloPacket
yield this.wait(packets_1.PacketType.HELLO, Peer.RESPONSE_TIMEOUT);
}
});
this.finalizeOpen = () => {
assert_1.default(!this.closed);
// Setup the ping interval
this.pingTimer = setInterval(this.sendPing, Peer.PING_INTERVAL);
};
/**
* Wait for a packet to be received from peer. Executed on timeout or once packet is received.
*/
this.wait = (packetId, timeout) => {
const entry = this.getOrAddPendingResponseEntry(packetId);
return new Promise((resolve, reject) => {
entry.addJob(resolve, reject);
if (timeout) {
entry.setTimeout(timeout);
}
});
};
/**
* Potentially timeout peer if it hasn't responded.
*/
this.checkTimeout = () => {
const now = utils_1.ms();
for (const [packetType, entry] of this.responseMap) {
if (now > entry.timeout) {
this.error(`Peer (${this.nodePubKey}) is stalling (${packetType})`);
this.close();
return;
}
}
};
/**
* Wait for a packet to be received from peer.
*/
this.addResponseTimeout = (packetId, timeout) => {
if (this.closed) {
return undefined;
}
const entry = this.getOrAddPendingResponseEntry(packetId);
entry.setTimeout(timeout);
return entry;
};
this.getOrAddPendingResponseEntry = (packetId) => {
let entry = this.responseMap.get(packetId);
if (!entry) {
entry = new PendingResponseEntry();
this.responseMap.set(packetId, entry);
}
return entry;
};
/**
* Fulfill a pending response entry for solicited responses, penalize unsolicited responses.
* @returns false if no pending response entry exists for the provided key, otherwise true
*/
this.fulfillResponseEntry = (packet) => {
const { reqId } = packet.header;
if (!reqId) {
this.logger.debug(`Peer (${this.nodePubKey}) sent a response packet without reqId`);
// TODO: penalize
return false;
}
const entry = this.responseMap.get(reqId);
if (!entry) {
this.logger.debug(`Peer (${this.nodePubKey}) sent an unsolicited response packet (${reqId})`);
// TODO: penalize
return false;
}
this.responseMap.delete(reqId);
entry.resolve(packet);
return true;
};
this.connect = (address) => {
assert_1.default(!this.socket);
const socket = net_1.default.connect(address.port, address.host);
this.socketAddress = address;
this.inbound = false;
this.connected = false;
this.bindSocket(socket);
};
this.accept = (socket) => {
assert_1.default(!this.socket);
this.socketAddress = addressUtils_1.default.fromSocket(socket);
this.inbound = true;
this.connected = true;
this.bindSocket(socket);
};
this.bindSocket = (socket) => {
assert_1.default(!this.socket);
this.socket = socket;
this.socket.once('error', (err) => {
if (!this.connected) {
return;
}
this.error(err);
// socket close event will be called immediately after the socket error
});
this.socket.once('close', (hadError) => {
// emitted once the socket is fully closed
if (this.connected) {
// don't log anything on close if we've never connected as the connection failure is logged elsewhere
if (this.nodePubKey === undefined) {
this.logger.info(`Socket closed prior to handshake (${addressUtils_1.default.toString(this.socketAddress)})`);
}
else if (hadError) {
this.logger.warn(`Peer ${this.nodePubKey} socket closed due to error`);
}
else {
this.logger.info(`Peer ${this.nodePubKey} socket closed`);
}
}
this.close();
});
this.socket.on('data', (data) => {
this.lastRecv = Date.now();
const dataStr = data.toString();
if (this.nodePubKey !== undefined) {
this.logger.debug(`Received data (${this.nodePubKey}): ${dataStr}`);
}
else {
this.logger.debug(`Received data (${addressUtils_1.default.toString(this.socketAddress)}): ${data.toString()}`);
}
this.parser.feed(dataStr);
});
this.socket.setNoDelay(true);
};
this.bindParser = (parser) => {
parser.on('packet', this.handlePacket);
parser.on('error', (err) => {
if (this.closed) {
return;
}
switch (err.type) {
case Parser_1.ParserErrorType.UNPARSEABLE_MESSAGE:
this.logger.warn(`Unparsable peer message: ${err.payload}`);
this.increaseBan(10);
break;
case Parser_1.ParserErrorType.INVALID_MESSAGE:
this.logger.warn(`Invalid peer message: ${err.payload}`);
this.increaseBan(10);
break;
case Parser_1.ParserErrorType.UNKNOWN_PACKET_TYPE:
this.logger.warn(`Unknown peer message type: ${err.payload}`);
this.increaseBan(20);
}
});
};
/** Check if a given packet is solicited and fulfill the pending response entry if it's a response. */
this.isPacketSolicited = (packet) => {
let solicted = true;
if (packet.direction === packets_1.PacketDirection.RESPONSE) {
// lookup a pending response entry for this packet by its reqId
if (!this.fulfillResponseEntry(packet)) {
solicted = false;
}
}
return solicted;
};
this.handlePacket = (packet) => {
if (this.isPacketSolicited(packet)) {
switch (packet.type) {
case packets_1.PacketType.HELLO: {
this.handleHello(packet);
break;
}
case packets_1.PacketType.PING: {
this.handlePing(packet);
break;
}
case packets_1.PacketType.DEAL_REQUEST: {
this.handleDealRequest(packet);
break;
}
case packets_1.PacketType.DEAL_RESPONSE: {
this.handleDealResponse(packet);
break;
}
case packets_1.PacketType.SWAP_REQUEST: {
this.handleSwapRequest(packet);
break;
}
case packets_1.PacketType.SWAP_RESPONSE: {
this.handleSwapResponse(packet);
break;
}
default:
this.emit('packet', packet);
break;
}
}
};
this.error = (err) => {
if (this.closed) {
return;
}
if (err instanceof Error) {
this.emit('error', err);
}
else {
this.emit('error', new Error(err));
}
};
this.sendHello = (handshakeData) => {
// TODO: use real values
const packet = new packets.HelloPacket(handshakeData);
this.sendPacket(packet);
return packet;
};
this.handleHello = (packet) => {
const entry = this.responseMap.get(packets_1.PacketType.HELLO);
if (!entry) {
this.logger.debug(`Peer (${this.nodePubKey}) sent an unsolicited Hello packet`);
// TODO: penalize
}
else {
this.responseMap.delete(packets_1.PacketType.HELLO);
entry.resolve(packet);
this.handshakeState = packet.body;
}
};
this.sendPing = () => {
const packet = new packets.PingPacket();
this.sendPacket(packet);
return packet;
};
this.handlePing = (packet) => {
this.sendPong(packet.header.id);
};
this.handleDealRequest = (request) => {
this.logger.debug('Got this: ' + JSON.stringify(request));
const requestBody = request.body;
if (!requestBody) {
return;
}
const preImage = crypto_1.randomBytes(32).toString('hex');
const hash = crypto_1.createHash('sha256');
const r_hash = hash.update(preImage).digest('hex');
const makerDealId = crypto_1.randomBytes(32).toString('hex');
let makerPubKey;
switch (requestBody.makerCoin) {
case enums_1.CurrencyType.BTC:
makerPubKey = this.pool.lndBtcClient ? this.pool.lndBtcClient.pubKey : undefined;
break;
case enums_1.CurrencyType.LTC:
makerPubKey = this.pool.lndLtcClient ? this.pool.lndLtcClient.pubKey : undefined;
break;
default:
return;
}
if (!makerPubKey) {
// TODO: proper error handling.
return;
}
const deal = {
makerDealId,
makerPubKey,
preImage,
r_hash,
myRole: enums_1.SwapDealRole.Maker,
takerAmount: requestBody.takerAmount,
takerCoin: requestBody.takerCoin,
takerPubKey: requestBody.takerPubKey,
takerDealId: requestBody.takerDealId,
makerAmount: requestBody.makerAmount,
makerCoin: requestBody.makerCoin,
createTime: Date.now(),
};
const body = {
makerPubKey,
makerDealId,
r_hash,
takerDealId: requestBody.takerDealId,
};
this.pool.swapDeals.add(deal);
this.logger.debug('swap deal: ' + JSON.stringify(deal));
this.logger.debug('sending back to peer: ' + JSON.stringify(body));
const packet = new packets.DealResponse(body, request.header.id);
this.sendPacket(packet);
};
this.handleDealResponse = (response) => {
if (!response.body) {
return;
}
const deal = this.pool.swapDeals.get(enums_1.SwapDealRole.Taker, response.body.takerDealId);
if (!deal) {
return;
}
deal.makerPubKey = response.body.makerPubKey;
deal.makerDealId = response.body.makerDealId;
deal.r_hash = response.body.r_hash;
this.logger.debug('updated deal: ' + JSON.stringify(this.pool.swapDeals.get(enums_1.SwapDealRole.Taker, response.body.takerDealId)));
const body = {
makerDealId: deal.makerDealId,
};
const packet = new packets.SwapRequest(body);
this.sendPacket(packet);
};
this.handleSwapRequest = (request) => {
if (!request.body) {
return;
}
const deal = this.pool.swapDeals.get(enums_1.SwapDealRole.Maker, request.body.makerDealId);
if (!deal) {
return;
}
const body = {
r_preimage: deal.preImage,
};
const packet = new packets.SwapResponse(body, request.header.id);
this.logger.debug('sending back to peer: ' + JSON.stringify(body));
this.sendPacket(packet);
};
this.handleSwapResponse = (response) => {
if (!response) {
return;
}
if (!response.body) {
return;
}
this.logger.debug('Swap completed. r_preimage = ' + response.body.r_preimage);
};
this.sendPong = (pingId) => {
const packet = new packets.PongPacket(undefined, pingId);
this.sendPacket(packet);
return packet;
};
this.bindParser(this.parser);
}
get nodePubKey() {
return this.handshakeState ? this.handshakeState.nodePubKey : undefined;
}
get addresses() {
return this.handshakeState ? this.handshakeState.addresses : undefined;
}
get info() {
return {
address: addressUtils_1.default.toString(this.socketAddress),
nodePubKey: this.handshakeState ? this.handshakeState.nodePubKey : undefined,
inbound: this.inbound,
pairs: this.handshakeState ? this.handshakeState.pairs : undefined,
xudVersion: this.handshakeState ? this.handshakeState.version : undefined,
secondsConnected: Math.round((Date.now() - this.connectTime) / 1000),
};
}
/** Create an outbound connection to a node. */
static fromOutbound(address, logger, pool) {
const peer = new Peer(logger, pool);
peer.connect(address);
return peer;
}
static fromInbound(socket, logger, pool) {
const peer = new Peer(logger, pool);
peer.accept(socket);
return peer;
}
}
/** Interval to check required responses from peer. */
Peer.STALL_INTERVAL = 5000;
/** Interval for pinging peers. */
Peer.PING_INTERVAL = 30000;
/** Socket connection timeout for outbound peers. */
Peer.CONNECTION_TIMEOUT = 10000;
/** Response timeout for response packets. */
Peer.RESPONSE_TIMEOUT = 10000;
/** A class representing a wait for an anticipated response packet from a peer. */
class PendingResponseEntry {
constructor() {
this.timeout = 0;
/** An array of tasks to resolve or reject. */
this.jobs = [];
this.addJob = (resolve, reject) => {
this.jobs.push(new Job(resolve, reject));
};
this.setTimeout = (timeout) => {
this.timeout = utils_1.ms() + timeout;
};
this.resolve = (result) => {
for (const job of this.jobs) {
job.resolve(result);
}
this.jobs.length = 0;
};
this.reject = (err) => {
for (const job of this.jobs) {
job.reject(err);
}
this.jobs.length = 0;
};
}
}
/** A pair of functions for resolving or rejecting a task. */
class Job {
constructor(resolve, reject) {
this.resolve = resolve;
this.reject = reject;
}
}
exports.default = Peer;
//# sourceMappingURL=Peer.js.map