streamium
Version:
Decentralized trustless video streaming using bitcoin payment channels.
456 lines (385 loc) • 14.7 kB
JavaScript
'use strict';
angular.module('streamium.provider.service', [])
.service('StreamiumProvider', function(bitcore, channel, events, inherits, Insight, Duration, PeerJS) {
var Provider = channel.Provider;
var SECONDS_IN_MINUTE = 60;
var MILLIS_IN_SECOND = 1000;
var MILLIS_IN_MINUTE = MILLIS_IN_SECOND * SECONDS_IN_MINUTE;
function StreamiumProvider() {
this.network = bitcore.Networks.get(config.network);
bitcore.Networks.defaultNetwork = this.network;
this.address = this.streamId = this.rate = null;
// TODO: this screams for a status object or add status into Provider
this.totalMoney = 0;
this.clientMaxActive = 0;
this.mapClientIdToStatus = {};
this.mapClientIdToProvider = {};
this.config = PeerJS.primary;
events.EventEmitter.call(this);
}
inherits(StreamiumProvider, events.EventEmitter);
StreamiumProvider.STATUS = {
disconnected: 'disconnected', // finished abnormally
connecting: 'connecting', // started the dance
waiting: 'waiting', // waiting for first payment (plus commitment tx)
ready: 'ready', // receiving payments
finished: 'finished'
};
StreamiumProvider.prototype.init = function(streamId, address, rate, isStatic, filename, callback) {
if (!streamId || !address || !rate || !callback) return callback('Invalid arguments');
try {
address = new bitcore.Address(address);
} catch (e) {
return callback('Invalid address');
}
this.isIdTaken = false;
this.streamId = streamId;
this.address = address;
this.rate = rate;
this.isStatic = isStatic;
this.filename = filename;
this.rateSatoshis = bitcore.Unit.fromBTC(rate).toSatoshis();
this.clientConnections = [];
this.clientConnectionMap = {};
this.clientColorsMap = {};
this.status = StreamiumProvider.STATUS.connecting
console.log('Connecting with peer', this.config);
this.peer = new Peer(this.streamId, this.config);
var self = this;
this.peer.on('open', function onOpen() {
console.log('Connected to peer server:', self.peer);
self.status = StreamiumProvider.STATUS.ready;
callback(null, self);
});
this.peer.on('close', function onClose() {
console.log('Connection to peer server closed');
self.status = StreamiumProvider.STATUS.finished;
});
this.peer.on('error', function onError(error) {
console.log('Error with peer server:', error);
error = getError(error);
if (error == StreamiumProvider.ERROR.IDISTAKEN) {
self.isIdTaken = true;
self.status = StreamiumProvider.STATUS.disconnected;
callback(error);
} else if (error == StreamiumProvider.ERROR.UNREACHABLE && self.canFallback()) {
self.config = PeerJS.secondary; // fallback peerjs server
return self.init(streamId, address, rate, callback);
} else if (!self.isIdTaken) {
self.status = StreamiumProvider.STATUS.disconnected;
callback(error);
}
});
this.peer.on('connection', function onConnection(connection) {
console.log('New peer connected:', connection);
self.clientConnections.push(connection);
self.clientConnectionMap[connection.peer] = connection;
self.clientColorsMap[connection.peer] = randomColor();
connection.on('data', function(data) {
console.log('New message:', data);
if (!data.type || !self.handlers[data.type]) throw 'Kernel panic'; // TODO!
self.handlers[data.type].call(self, connection, data.payload);
});
connection.on('error', function(error) {
console.log('Error with peer connection', error);
self.clientConnections.splice(self.clientConnections.indexOf(connection), 1);
delete self.clientConnectionMap[connection.peer];
});
connection.on('close', function() {
console.log('Client connection closed');
self.clientConnections.splice(self.clientConnections.indexOf(connection), 1);
delete self.clientConnectionMap[connection.peer];
});
});
// Init Provider
// Change status
};
StreamiumProvider.prototype.canFallback = function () {
return this.config == PeerJS.primary &&
this.status == StreamiumProvider.STATUS.connecting &&
!this.isIdTaken;
};
StreamiumProvider.prototype.pushVideo = function (peer, data) {
this.clientConnectionMap[peer].send({
type: 'video',
payload: {
data: data.end ? { end: true } : data
}
})
};
StreamiumProvider.prototype.handlers = {};
StreamiumProvider.prototype.handlers.hello = function(connection, data) {
if (connection.peer in this.mapClientIdToProvider) {
console.log('Error: Received `hello` from existing peer:', data);
return;
}
var stored = localStorage.getItem('privateKey');
var privateKey;
if (!stored) {
privateKey = new bitcore.PrivateKey();
localStorage.setItem('privateKey', privateKey.toString());
} else {
privateKey = new bitcore.PrivateKey(stored);
}
var provider = new Provider({
network: this.address.network,
paymentAddress: this.address,
key: privateKey
});
this.mapClientIdToProvider[connection.peer] = provider;
this.mapClientIdToStatus[connection.peer] = StreamiumProvider.STATUS.connecting;
connection.send({
type: 'hello',
payload: {
publicKey: provider.key.publicKey.toString(),
paymentAddress: this.address.toString(),
rate: this.rate,
isStatic: this.isStatic
}
});
};
StreamiumProvider.prototype.handlers.sign = function(connection, data) {
var provider = this.mapClientIdToProvider[connection.peer];
var status = this.mapClientIdToStatus[connection.peer];
if (status !== StreamiumProvider.STATUS.connecting) {
console.log('Error: Received `sign` from a non-existing or connected peer:', data);
return;
}
this.mapClientIdToStatus[connection.peer] = StreamiumProvider.STATUS.waiting;
if (typeof data === 'string') {
data = JSON.parse(data);
}
provider.signRefund(data);
var refund = provider.refund;
connection.send({
type: 'refundAck',
payload: refund.toJSON()
});
};
StreamiumProvider.prototype.handlers.message = function(connection, message) {
var status = this.mapClientIdToStatus[connection.peer];
if (status !== StreamiumProvider.STATUS.ready) {
return;
}
var data = {
color: this.clientColorsMap[connection.peer],
message: message
};
this.broadcastToConnected(data);
this.emit('chatroom:message', data);
};
StreamiumProvider.prototype.getConnected = function() {
var count = 0;
for (var i in this.mapClientIdToStatus) {
count += (this.mapClientIdToStatus[i] === StreamiumProvider.STATUS.ready);
}
return count;
};
StreamiumProvider.prototype.endBroadcast = function(peerId) {
this.mapClientIdToStatus[peerId] = StreamiumProvider.STATUS.finished;
var payment = this.mapClientIdToProvider[peerId].paymentTx;
var self = this;
Insight.broadcast(payment.serialize(), function(err, txid) {
if (err) {
console.log('Error broadcasting ' + payment);
console.log(err);
} else {
localStorage.removeItem('payment_' + self.streamId + '_' + peerId);
console.log('Payment broadcasted correctly', txid);
var connection = self.clientConnectionMap[peerId];
connection.send({
type: 'end'
});
}
self.emit('broadcast:end', peerId);
});
};
StreamiumProvider.prototype.getFinalExpirationFor = function(provider) {
return provider.startTime + Duration.for(this.rate, provider.refund.outputAmount);
};
StreamiumProvider.prototype.handlers.commitment = function(connection, data) {
if (typeof data === 'string') {
data = JSON.parse(data);
}
var commitment = new channel.Transactions.Commitment(data);
var self = this;
Insight.broadcast(commitment.serialize(), function(err, txid) {
if (err) {
console.log(err);
self.emit('broadcast:end', connection);
} else {
console.log('Commitment tx broadcasted', txid);
}
});
};
StreamiumProvider.prototype.handlers.payment = function(connection, data) {
var provider = this.mapClientIdToProvider[connection.peer];
var status = this.mapClientIdToStatus[connection.peer];
var firstPayment = false;
if (status === StreamiumProvider.STATUS.waiting) {
status = this.mapClientIdToStatus[connection.peer] = StreamiumProvider.STATUS.ready;
firstPayment = true;
this.emit('client-watching', { peerId: connection.peer });
}
if (status !== StreamiumProvider.STATUS.ready) {
console.log('Error: Received `payment` from a non-existing or unprepared peer:', data);
return;
}
if (typeof data === 'string') {
data = JSON.parse(data);
}
provider.validPayment(data);
var self = this;
var updatePayment = function() {
var refundExpiration = self.getFinalExpirationFor(provider);
var paymentsExpiration = provider.startTime + Duration.for(self.rate, provider.currentAmount);
var expiration = Math.min(refundExpiration, paymentsExpiration);
clearTimeout(provider.timeout);
provider.timeout = setTimeout(function() {
console.log('Payment channel out of funds for ', connection.peer);
clearTimeout(provider.timeout);
self.endBroadcast(connection.peer);
}, Math.min(expiration, refundExpiration) - new Date().getTime());
console.log(connection.peer + ' expires at ' + new Date(expiration));
// console.log('Current time is ' + new Date());
// console.log('Funds will run out at ' + new Date(refundExpiration));
localStorage.setItem('payment_' + self.streamId + '_' + connection.peer, provider.paymentTx.toString());
self.totalMoney = 0;
for (var providerId in self.mapClientIdToProvider) {
self.totalMoney += self.mapClientIdToProvider[providerId].currentAmount;
}
self.emit('balanceUpdated', self.totalMoney);
try {
connection.send({
type: 'paymentAck',
payload: {
success: true,
}
});
} catch(e) {
console.log(e);
self.endBroadcast(connection.peer);
}
};
if (firstPayment) {
return this.paymentVerification(data, function(err) {
if (err) {
console.log('Error accepting commitment:', err);
return self.endBroadcast(connection.peer);
}
provider.startTime = new Date().getTime();
updatePayment();
self.clientMaxActive = Math.max(self.clientMaxActive, self.getConnected());
self.emit('broadcast:start', connection.peer);
});
} else {
updatePayment();
}
};
StreamiumProvider.prototype.paymentVerification = function(data, callback) {
var maxRetry = config.confidenceRetry;
var targetConfidence = config.confidenceTarget;
var retryDelay = config.confidenceDelay;
var txid = data.transaction.inputs[0].prevTxId.toString('hex');
var started = false;
var confidenceReached = false;
var tryFetch = function(retry) {
return function() {
if (retry > maxRetry) {
return callback(StreamiumProvider.ERROR.UNCONFIRMED);
}
$.ajax({
url: config.BLOCKCYPHERTX + txid,
dataType: 'json'
}).done(function(confidence) {
if (confidence.double_spend) {
return callback(StreamiumProvider.ERROR.DOUBLESPEND);
}
if (!started) {
started = true;
callback();
}
if (confidence.block_hash) {
console.log('Transaction inserted into a block. No more doublespend checks');
return;
}
if (confidence.confidence > targetConfidence) {
if (!confidenceReached) {
console.log('Confidence reached target! Will continue to check for doublespends');
confidenceReached = true;
}
return setTimeout(
tryFetch(retry), /* no increment */
retryDelay * 10 /* less stress on server */
);
}
console.log('Confidence at '
+ confidence.confidence
+ '; waiting to reach ' + targetConfidence
);
return setTimeout(tryFetch(retry + 1), retryDelay);
}).fail(function(err) {
return setTimeout(tryFetch(retry + 1), retryDelay);
});
};
};
tryFetch(0)();
};
StreamiumProvider.prototype.endAllBroadcasts = function() {
var self = this;
async.map(_.keys(this.mapClientIdToProvider), function(client) {
if (self.mapClientIdToStatus[client] === StreamiumProvider.STATUS.ready) {
self.endBroadcast(client);
}
});
};
StreamiumProvider.prototype.getLink = function() {
if (this.status === StreamiumProvider.STATUS.disconnected) throw 'Invalid State';
var baseURL = window.location.origin;
return baseURL + config.appPrefix + '/s/' + this.streamId;
};
StreamiumProvider.prototype.sendMessage = function(message) {
var data = {
color: this.clientColorsMap[this.peer.id],
message: message
};
this.broadcastMessage(data);
this.emit('chatroom:message', data);
};
StreamiumProvider.prototype.broadcastMessage = function(data) {
this.clientConnections.forEach(function(connection) {
connection.send({
type: 'message',
payload: data
});
});
};
StreamiumProvider.prototype.broadcastToConnected = function(data) {
var self = this;
this.clientConnections.forEach(function(connection) {
if (self.mapClientIdToStatus[connection.peer] === StreamiumProvider.STATUS.ready) {
connection.send({
type: 'message',
payload: data
});
}
});
};
StreamiumProvider.ERROR = {
UNCONFIRMED: 'Unconfirmed',
DOUBLESPEND: 'Double spend detected',
IDISTAKEN : 'Id is taken',
UNREACHABLE: 'Server is unreachable'
};
StreamiumProvider.prototype.ERROR = StreamiumProvider.ERROR;
function getError(error) {
if (error.type == "unavailable-id") {
return StreamiumProvider.ERROR.IDISTAKEN;
} else if (error.type == "network") {
return StreamiumProvider.ERROR.UNREACHABLE;
} else {
throw new Error("Unknown error");
}
}
return new StreamiumProvider();
});