2022-07-06 11:58:06 +02:00
import DB from '../../database' ;
import logger from '../../logger' ;
import channelsApi from '../../api/explorer/channels.api' ;
import bitcoinClient from '../../api/bitcoin/bitcoin-client' ;
import bitcoinApi from '../../api/bitcoin/bitcoin-api-factory' ;
import config from '../../config' ;
import { IEsploraApi } from '../../api/bitcoin/esplora-api.interface' ;
import { ILightningApi } from '../../api/lightning/lightning-api.interface' ;
2022-07-11 17:52:38 +02:00
import { $lookupNodeLocation } from './sync-tasks/node-locations' ;
2022-07-29 17:41:09 +02:00
import lightningApi from '../../api/lightning/lightning-api-factory' ;
2022-08-01 19:42:33 +02:00
import { convertChannelId } from '../../api/lightning/clightning/clightning-convert' ;
2022-08-04 13:05:15 +02:00
import { Common } from '../../api/common' ;
2022-04-24 01:33:38 +04:00
2022-08-02 16:18:19 +02:00
class NetworkSyncService {
2022-04-24 01:33:38 +04:00
constructor ( ) { }
2022-07-04 12:00:16 +02:00
public async $startService() {
2022-04-24 01:33:38 +04:00
logger . info ( 'Starting node sync service' ) ;
2022-07-06 11:58:06 +02:00
await this . $runUpdater ( ) ;
2022-04-24 01:33:38 +04:00
setInterval ( async ( ) = > {
2022-07-06 11:58:06 +02:00
await this . $runUpdater ( ) ;
2022-04-24 01:33:38 +04:00
} , 1000 * 60 * 60 ) ;
}
2022-08-04 13:05:15 +02:00
private async $runUpdater ( ) : Promise < void > {
2022-04-24 01:33:38 +04:00
try {
2022-07-06 11:58:06 +02:00
logger . info ( ` Updating nodes and channels... ` ) ;
2022-04-24 01:33:38 +04:00
const networkGraph = await lightningApi . $getNetworkGraph ( ) ;
2022-08-02 16:18:19 +02:00
if ( networkGraph . nodes . length === 0 || networkGraph . edges . length === 0 ) {
logger . info ( ` LN Network graph is empty, retrying in 10 seconds ` ) ;
2022-08-04 13:05:15 +02:00
await Common . sleep $ ( 10000 ) ;
this . $runUpdater ( ) ;
2022-08-02 16:18:19 +02:00
return ;
}
2022-04-24 01:33:38 +04:00
for ( const node of networkGraph . nodes ) {
await this . $saveNode ( node ) ;
}
2022-07-06 11:58:06 +02:00
logger . info ( ` Nodes updated. ` ) ;
2022-04-24 01:33:38 +04:00
2022-07-11 17:52:38 +02:00
if ( config . MAXMIND . ENABLED ) {
await $lookupNodeLocation ( ) ;
}
2022-07-27 17:21:24 +02:00
const graphChannelsIds : string [ ] = [ ] ;
2022-07-29 20:53:19 +02:00
for ( const channel of networkGraph . edges ) {
2022-04-24 01:33:38 +04:00
await this . $saveChannel ( channel ) ;
2022-07-29 20:53:19 +02:00
graphChannelsIds . push ( channel . channel_id ) ;
2022-04-24 01:33:38 +04:00
}
2022-07-27 17:21:24 +02:00
await this . $setChannelsInactive ( graphChannelsIds ) ;
2022-07-06 11:58:06 +02:00
logger . info ( ` Channels updated. ` ) ;
2022-05-01 15:35:28 +04:00
2022-05-03 20:18:07 +04:00
await this . $findInactiveNodesAndChannels ( ) ;
2022-05-05 23:19:24 +04:00
await this . $lookUpCreationDateFromChain ( ) ;
await this . $updateNodeFirstSeen ( ) ;
2022-06-29 23:06:13 +02:00
await this . $scanForClosedChannels ( ) ;
2022-07-09 17:45:34 +02:00
if ( config . MEMPOOL . BACKEND === 'esplora' ) {
await this . $runClosedChannelsForensics ( ) ;
}
2022-06-29 23:06:13 +02:00
2022-04-24 01:33:38 +04:00
} catch ( e ) {
2022-07-29 20:53:19 +02:00
logger . err ( '$runUpdater() error: ' + ( e instanceof Error ? e.message : e ) ) ;
2022-04-24 01:33:38 +04:00
}
}
2022-05-05 23:19:24 +04:00
// This method look up the creation date of the earliest channel of the node
// and update the node to that date in order to get the earliest first seen date
private async $updateNodeFirstSeen() {
try {
const [ nodes ] : any [ ] = await DB . query ( ` SELECT nodes.public_key, UNIX_TIMESTAMP(nodes.first_seen) AS first_seen, (SELECT UNIX_TIMESTAMP(created) FROM channels WHERE channels.node1_public_key = nodes.public_key ORDER BY created ASC LIMIT 1) AS created1, (SELECT UNIX_TIMESTAMP(created) FROM channels WHERE channels.node2_public_key = nodes.public_key ORDER BY created ASC LIMIT 1) AS created2 FROM nodes ` ) ;
for ( const node of nodes ) {
let lowest = 0 ;
if ( node . created1 ) {
if ( node . created2 && node . created2 < node . created1 ) {
lowest = node . created2 ;
} else {
lowest = node . created1 ;
}
} else if ( node . created2 ) {
lowest = node . created2 ;
}
if ( lowest && lowest < node . first_seen ) {
const query = ` UPDATE nodes SET first_seen = FROM_UNIXTIME(?) WHERE public_key = ? ` ;
const params = [ lowest , node . public_key ] ;
await DB . query ( query , params ) ;
}
}
2022-07-06 11:58:06 +02:00
logger . info ( ` Node first seen dates scan complete. ` ) ;
2022-05-05 23:19:24 +04:00
} catch ( e ) {
logger . err ( '$updateNodeFirstSeen() error: ' + ( e instanceof Error ? e.message : e ) ) ;
}
}
private async $lookUpCreationDateFromChain() {
2022-07-06 11:58:06 +02:00
logger . info ( ` Running channel creation date lookup... ` ) ;
2022-05-05 23:19:24 +04:00
try {
const channels = await channelsApi . $getChannelsWithoutCreatedDate ( ) ;
for ( const channel of channels ) {
const transaction = await bitcoinClient . getRawTransaction ( channel . transaction_id , 1 ) ;
await DB . query ( ` UPDATE channels SET created = FROM_UNIXTIME(?) WHERE channels.id = ? ` , [ transaction . blocktime , channel . id ] ) ;
}
2022-07-06 11:58:06 +02:00
logger . info ( ` Channel creation dates scan complete. ` ) ;
2022-05-05 23:19:24 +04:00
} catch ( e ) {
logger . err ( '$setCreationDateFromChain() error: ' + ( e instanceof Error ? e.message : e ) ) ;
}
}
2022-05-03 20:18:07 +04:00
// Looking for channels whos nodes are inactive
private async $findInactiveNodesAndChannels ( ) : Promise < void > {
2022-07-06 11:58:06 +02:00
logger . info ( ` Running inactive channels scan... ` ) ;
2022-05-03 20:18:07 +04:00
try {
2022-07-29 20:53:19 +02:00
const [ channels ] : [ { id : string } [ ] ] = await < any > DB . query ( `
2022-07-27 17:21:24 +02:00
SELECT channels . id
FROM channels
WHERE channels . status = 1
AND (
(
SELECT COUNT ( * )
FROM nodes
WHERE nodes . public_key = channels . node1_public_key
) = 0
OR (
SELECT COUNT ( * )
FROM nodes
WHERE nodes . public_key = channels . node2_public_key
) = 0 )
` );
2022-05-03 20:18:07 +04:00
for ( const channel of channels ) {
await this . $updateChannelStatus ( channel . id , 0 ) ;
}
2022-07-06 11:58:06 +02:00
logger . info ( ` Inactive channels scan complete. ` ) ;
2022-05-03 20:18:07 +04:00
} catch ( e ) {
logger . err ( '$findInactiveNodesAndChannels() error: ' + ( e instanceof Error ? e.message : e ) ) ;
}
}
private async $scanForClosedChannels ( ) : Promise < void > {
2022-05-01 15:35:28 +04:00
try {
2022-07-06 11:58:06 +02:00
logger . info ( ` Starting closed channels scan... ` ) ;
2022-05-01 15:35:28 +04:00
const channels = await channelsApi . $getChannelsByStatus ( 0 ) ;
for ( const channel of channels ) {
2022-06-29 23:06:13 +02:00
const spendingTx = await bitcoinApi . $getOutspend ( channel . transaction_id , channel . transaction_vout ) ;
if ( spendingTx . spent === true && spendingTx . status ? . confirmed === true ) {
2022-05-01 15:35:28 +04:00
logger . debug ( 'Marking channel: ' + channel . id + ' as closed.' ) ;
2022-07-03 20:37:01 +02:00
await DB . query ( ` UPDATE channels SET status = 2, closing_date = FROM_UNIXTIME(?) WHERE id = ? ` ,
[ spendingTx . status . block_time , channel . id ] ) ;
2022-06-29 23:06:13 +02:00
if ( spendingTx . txid && ! channel . closing_transaction_id ) {
await DB . query ( ` UPDATE channels SET closing_transaction_id = ? WHERE id = ? ` , [ spendingTx . txid , channel . id ] ) ;
}
2022-05-01 15:35:28 +04:00
}
}
2022-07-06 11:58:06 +02:00
logger . info ( ` Closed channels scan complete. ` ) ;
2022-05-01 15:35:28 +04:00
} catch ( e ) {
2022-06-29 23:06:13 +02:00
logger . err ( '$scanForClosedChannels() error: ' + ( e instanceof Error ? e.message : e ) ) ;
}
}
/ *
1 . Mutually closed
2 . Forced closed
3 . Forced closed with penalty
* /
private async $runClosedChannelsForensics ( ) : Promise < void > {
if ( ! config . ESPLORA . REST_API_URL ) {
return ;
2022-05-01 15:35:28 +04:00
}
2022-06-29 23:06:13 +02:00
try {
2022-07-06 11:58:06 +02:00
logger . info ( ` Started running closed channel forensics... ` ) ;
2022-06-29 23:06:13 +02:00
const channels = await channelsApi . $getClosedChannelsWithoutReason ( ) ;
for ( const channel of channels ) {
let reason = 0 ;
// Only Esplora backend can retrieve spent transaction outputs
const outspends = await bitcoinApi . $getOutspends ( channel . closing_transaction_id ) ;
const lightningScriptReasons : number [ ] = [ ] ;
for ( const outspend of outspends ) {
if ( outspend . spent && outspend . txid ) {
const spendingTx = await bitcoinApi . $getRawTransaction ( outspend . txid ) ;
const lightningScript = this . findLightningScript ( spendingTx . vin [ outspend . vin || 0 ] ) ;
lightningScriptReasons . push ( lightningScript ) ;
}
}
if ( lightningScriptReasons . length === outspends . length
&& lightningScriptReasons . filter ( ( r ) = > r === 1 ) . length === outspends . length ) {
reason = 1 ;
} else {
const filteredReasons = lightningScriptReasons . filter ( ( r ) = > r !== 1 ) ;
if ( filteredReasons . length ) {
if ( filteredReasons . some ( ( r ) = > r === 2 || r === 4 ) ) {
reason = 3 ;
} else {
reason = 2 ;
}
} else {
/ *
We can detect a commitment transaction ( force close ) by reading Sequence and Locktime
https : //github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction
* /
const closingTx = await bitcoinApi . $getRawTransaction ( channel . closing_transaction_id ) ;
const sequenceHex : string = closingTx . vin [ 0 ] . sequence . toString ( 16 ) ;
const locktimeHex : string = closingTx . locktime . toString ( 16 ) ;
if ( sequenceHex . substring ( 0 , 2 ) === '80' && locktimeHex . substring ( 0 , 2 ) === '20' ) {
reason = 2 ; // Here we can't be sure if it's a penalty or not
} else {
reason = 1 ;
}
}
}
if ( reason ) {
logger . debug ( 'Setting closing reason ' + reason + ' for channel: ' + channel . id + '.' ) ;
await DB . query ( ` UPDATE channels SET closing_reason = ? WHERE id = ? ` , [ reason , channel . id ] ) ;
}
}
2022-07-06 11:58:06 +02:00
logger . info ( ` Closed channels forensics scan complete. ` ) ;
2022-06-29 23:06:13 +02:00
} catch ( e ) {
logger . err ( '$runClosedChannelsForensics() error: ' + ( e instanceof Error ? e.message : e ) ) ;
}
}
private findLightningScript ( vin : IEsploraApi.Vin ) : number {
const topElement = vin . witness [ vin . witness . length - 2 ] ;
if ( /^OP_IF OP_PUSHBYTES_33 \w{66} OP_ELSE OP_PUSH(NUM_\d+|BYTES_(1 \w{2}|2 \w{4})) OP_CSV OP_DROP OP_PUSHBYTES_33 \w{66} OP_ENDIF OP_CHECKSIG$/ . test ( vin . inner_witnessscript_asm ) ) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#commitment-transaction-outputs
if ( topElement === '01' ) {
// top element is '01' to get in the revocation path
// 'Revoked Lightning Force Close';
// Penalty force closed
return 2 ;
} else {
// top element is '', this is a delayed to_local output
// 'Lightning Force Close';
return 3 ;
}
} else if (
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_NOTIF OP_DROP OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/ . test ( vin . inner_witnessscript_asm ) ||
/^OP_DUP OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUAL OP_IF OP_CHECKSIG OP_ELSE OP_PUSHBYTES_33 \w{66} OP_SWAP OP_SIZE OP_PUSHBYTES_1 20 OP_EQUAL OP_IF OP_HASH160 OP_PUSHBYTES_20 \w{40} OP_EQUALVERIFY OP_PUSHNUM_2 OP_SWAP OP_PUSHBYTES_33 \w{66} OP_PUSHNUM_2 OP_CHECKMULTISIG OP_ELSE OP_DROP OP_PUSHBYTES_3 \w{6} OP_CLTV OP_DROP OP_CHECKSIG OP_ENDIF (OP_PUSHNUM_1 OP_CSV OP_DROP |)OP_ENDIF$/ . test ( vin . inner_witnessscript_asm )
) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#offered-htlc-outputs
// https://github.com/lightning/bolts/blob/master/03-transactions.md#received-htlc-outputs
if ( topElement . length === 66 ) {
// top element is a public key
// 'Revoked Lightning HTLC'; Penalty force closed
return 4 ;
} else if ( topElement ) {
// top element is a preimage
// 'Lightning HTLC';
return 5 ;
} else {
// top element is '' to get in the expiry of the script
// 'Expired Lightning HTLC';
return 6 ;
}
} else if ( /^OP_PUSHBYTES_33 \w{66} OP_CHECKSIG OP_IFDUP OP_NOTIF OP_PUSHNUM_16 OP_CSV OP_ENDIF$/ . test ( vin . inner_witnessscript_asm ) ) {
// https://github.com/lightning/bolts/blob/master/03-transactions.md#to_local_anchor-and-to_remote_anchor-output-option_anchors
if ( topElement ) {
// top element is a signature
// 'Lightning Anchor';
return 7 ;
} else {
// top element is '', it has been swept after 16 blocks
// 'Swept Lightning Anchor';
return 8 ;
}
}
return 1 ;
2022-05-01 15:35:28 +04:00
}
private async $saveChannel ( channel : ILightningApi.Channel ) : Promise < void > {
2022-07-29 20:53:19 +02:00
const [ txid , vout ] = channel . chan_point . split ( ':' ) ;
const policy1 : Partial < ILightningApi.RoutingPolicy > = channel . node1_policy || { } ;
const policy2 : Partial < ILightningApi.RoutingPolicy > = channel . node2_policy || { } ;
2022-05-03 20:55:34 +04:00
2022-04-24 01:33:38 +04:00
try {
const query = ` INSERT INTO channels
(
id ,
2022-05-03 20:55:34 +04:00
short_id ,
2022-04-24 01:33:38 +04:00
capacity ,
transaction_id ,
transaction_vout ,
updated_at ,
2022-05-01 15:35:28 +04:00
status ,
2022-04-24 01:33:38 +04:00
node1_public_key ,
node1_base_fee_mtokens ,
node1_cltv_delta ,
node1_fee_rate ,
node1_is_disabled ,
node1_max_htlc_mtokens ,
node1_min_htlc_mtokens ,
node1_updated_at ,
node2_public_key ,
node2_base_fee_mtokens ,
node2_cltv_delta ,
node2_fee_rate ,
node2_is_disabled ,
node2_max_htlc_mtokens ,
node2_min_htlc_mtokens ,
node2_updated_at
)
2022-05-03 20:55:34 +04:00
VALUES ( ? , ? , ? , ? , ? , ? , 1 , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? , ? )
2022-04-24 01:33:38 +04:00
ON DUPLICATE KEY UPDATE
capacity = ? ,
updated_at = ? ,
2022-05-01 15:35:28 +04:00
status = 1 ,
2022-04-24 01:33:38 +04:00
node1_public_key = ? ,
node1_base_fee_mtokens = ? ,
node1_cltv_delta = ? ,
node1_fee_rate = ? ,
node1_is_disabled = ? ,
node1_max_htlc_mtokens = ? ,
node1_min_htlc_mtokens = ? ,
node1_updated_at = ? ,
node2_public_key = ? ,
node2_base_fee_mtokens = ? ,
node2_cltv_delta = ? ,
node2_fee_rate = ? ,
node2_is_disabled = ? ,
node2_max_htlc_mtokens = ? ,
node2_min_htlc_mtokens = ? ,
node2_updated_at = ?
; ` ;
await DB . query ( query , [
2022-08-01 19:42:33 +02:00
this . toIntegerId ( channel . channel_id ) ,
2022-07-29 20:53:19 +02:00
this . toShortId ( channel . channel_id ) ,
2022-04-24 01:33:38 +04:00
channel . capacity ,
2022-07-29 20:53:19 +02:00
txid ,
vout ,
this . utcDateToMysql ( channel . last_update ) ,
channel . node1_pub ,
policy1 . fee_base_msat ,
policy1 . time_lock_delta ,
policy1 . fee_rate_milli_msat ,
policy1 . disabled ,
policy1 . max_htlc_msat ,
policy1 . min_htlc ,
this . utcDateToMysql ( policy1 . last_update ) ,
channel . node2_pub ,
policy2 . fee_base_msat ,
policy2 . time_lock_delta ,
policy2 . fee_rate_milli_msat ,
policy2 . disabled ,
policy2 . max_htlc_msat ,
policy2 . min_htlc ,
this . utcDateToMysql ( policy2 . last_update ) ,
2022-04-24 01:33:38 +04:00
channel . capacity ,
2022-07-29 20:53:19 +02:00
this . utcDateToMysql ( channel . last_update ) ,
channel . node1_pub ,
policy1 . fee_base_msat ,
policy1 . time_lock_delta ,
policy1 . fee_rate_milli_msat ,
policy1 . disabled ,
policy1 . max_htlc_msat ,
policy1 . min_htlc ,
this . utcDateToMysql ( policy1 . last_update ) ,
channel . node2_pub ,
policy2 . fee_base_msat ,
policy2 . time_lock_delta ,
policy2 . fee_rate_milli_msat ,
policy2 . disabled ,
policy2 . max_htlc_msat ,
policy2 . min_htlc ,
this . utcDateToMysql ( policy2 . last_update )
2022-04-24 01:33:38 +04:00
] ) ;
} catch ( e ) {
logger . err ( '$saveChannel() error: ' + ( e instanceof Error ? e.message : e ) ) ;
}
}
2022-07-29 20:53:19 +02:00
private async $updateChannelStatus ( channelId : string , status : number ) : Promise < void > {
2022-05-03 20:18:07 +04:00
try {
2022-07-29 20:53:19 +02:00
await DB . query ( ` UPDATE channels SET status = ? WHERE id = ? ` , [ status , channelId ] ) ;
2022-05-03 20:18:07 +04:00
} catch ( e ) {
logger . err ( '$updateChannelStatus() error: ' + ( e instanceof Error ? e.message : e ) ) ;
}
}
2022-07-27 17:21:24 +02:00
private async $setChannelsInactive ( graphChannelsIds : string [ ] ) : Promise < void > {
2022-08-02 16:18:19 +02:00
if ( graphChannelsIds . length === 0 ) {
return ;
}
2022-05-01 15:35:28 +04:00
try {
2022-07-27 17:21:24 +02:00
await DB . query ( `
UPDATE channels
SET status = 0
WHERE short_id NOT IN (
$ { graphChannelsIds . map ( id = > ` " ${ id } " ` ) . join ( ',' ) }
)
2022-07-27 17:24:31 +02:00
AND status != 2
2022-07-27 17:21:24 +02:00
` );
2022-05-01 15:35:28 +04:00
} catch ( e ) {
logger . err ( '$setChannelsInactive() error: ' + ( e instanceof Error ? e.message : e ) ) ;
}
}
private async $saveNode ( node : ILightningApi.Node ) : Promise < void > {
2022-04-24 01:33:38 +04:00
try {
2022-08-01 19:42:33 +02:00
const sockets = ( node . addresses ? . map ( a = > a . addr ) . join ( ',' ) ) ? ? '' ;
2022-04-24 01:33:38 +04:00
const query = ` INSERT INTO nodes(
public_key ,
first_seen ,
updated_at ,
alias ,
2022-05-05 23:19:24 +04:00
color ,
sockets
2022-04-24 01:33:38 +04:00
)
2022-08-01 19:42:33 +02:00
VALUES ( ? , NOW ( ) , FROM_UNIXTIME ( ? ) , ? , ? , ? )
ON DUPLICATE KEY UPDATE updated_at = FROM_UNIXTIME ( ? ) , alias = ? , color = ? , sockets = ? ` ;
2022-04-24 01:33:38 +04:00
await DB . query ( query , [
2022-07-29 20:53:19 +02:00
node . pub_key ,
2022-08-01 19:42:33 +02:00
node . last_update ,
2022-04-24 01:33:38 +04:00
node . alias ,
node . color ,
2022-05-05 23:19:24 +04:00
sockets ,
2022-08-01 19:42:33 +02:00
node . last_update ,
2022-04-24 01:33:38 +04:00
node . alias ,
node . color ,
2022-05-05 23:19:24 +04:00
sockets ,
2022-04-24 01:33:38 +04:00
] ) ;
} catch ( e ) {
logger . err ( '$saveNode() error: ' + ( e instanceof Error ? e.message : e ) ) ;
}
}
2022-08-01 19:42:33 +02:00
private toIntegerId ( id : string ) : string {
2022-08-08 07:50:50 +02:00
if ( config . LIGHTNING . BACKEND === 'cln' ) {
return convertChannelId ( id ) ;
}
else if ( config . LIGHTNING . BACKEND === 'lnd' ) {
2022-08-01 19:42:33 +02:00
return id ;
}
2022-08-08 07:50:50 +02:00
return '' ;
2022-08-01 19:42:33 +02:00
}
2022-07-29 20:53:19 +02:00
/** Decodes a channel id returned by lnd as uint64 to a short channel id */
private toShortId ( id : string ) : string {
2022-08-01 19:42:33 +02:00
if ( config . LIGHTNING . BACKEND === 'cln' ) {
return id ;
}
2022-07-29 20:53:19 +02:00
const n = BigInt ( id ) ;
return [
n >> 40 n , // nth block
( n >> 16 n ) & 0xffffff n , // nth tx of the block
n & 0xffff n // nth output of the tx
] . join ( 'x' ) ;
}
private utcDateToMysql ( date? : number ) : string {
const d = new Date ( ( date || 0 ) * 1000 ) ;
2022-04-24 01:33:38 +04:00
return d . toISOString ( ) . split ( 'T' ) [ 0 ] + ' ' + d . toTimeString ( ) . split ( ' ' ) [ 0 ] ;
}
}
2022-08-02 16:18:19 +02:00
export default new NetworkSyncService ( ) ;