This repository has been archived by the owner on May 5, 2022. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 15
/
connection.ts
1770 lines (1592 loc) · 71.2 KB
/
connection.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { EventEmitter } from 'events'
import createLogger from 'ilp-logger'
import { DataAndMoneyStream } from './stream'
import * as IlpPacket from 'ilp-packet'
import * as cryptoHelper from './crypto'
import {
Packet,
Frame,
StreamMoneyFrame,
StreamCloseFrame,
StreamDataFrame,
StreamMaxMoneyFrame,
FrameType,
IlpPacketType,
ConnectionNewAddressFrame,
ConnectionAssetDetailsFrame,
ErrorCode,
ConnectionCloseFrame,
ConnectionStreamIdBlockedFrame,
ConnectionMaxStreamIdFrame,
StreamMaxDataFrame,
StreamDataBlockedFrame,
StreamReceiptFrame,
ConnectionMaxDataFrame,
ConnectionDataBlockedFrame,
StreamMoneyBlockedFrame
} from './packet'
import { Reader, Writer } from 'oer-utils'
import { CongestionController } from './util/congestion'
import { Plugin } from './util/plugin-interface'
import {
maxLong,
minLong,
minLongs,
countDigits,
checkedAdd,
checkedSubtract,
multiplyDivideFloor
} from './util/long'
import * as Long from 'long'
import Rational from './util/rational'
import { createReceipt, RECEIPT_VERSION } from './util/receipt'
import { StoppableTimeout } from './util/stoppable-timeout'
import { v4 as uuid } from 'uuid'
const RETRY_DELAY_START = 100
const RETRY_DELAY_MAX = 43200000 // 12 hours should be long enough
const RETRY_DELAY_INCREASE_FACTOR = 1.5
const DEFAULT_PACKET_TIMEOUT = 30000
const DEFAULT_IDLE_TIMEOUT = 60000 // 1 minute
const MAX_DATA_SIZE = 32767
const DEFAULT_MAX_REMOTE_STREAMS = 10
const DEFAULT_MINIMUM_EXCHANGE_RATE_PRECISION = 3
const TEST_PACKET_MAX_ATTEMPTS = 15
export interface ConnectionOpts {
/** Token in the ILP address uniquely identifying this connection */
connectionId?: string,
/** Ledger plugin (V2) */
plugin: Plugin,
/** ILP Address of the remote entity */
destinationAccount?: string,
/** ILP Address of the plugin */
sourceAccount?: string,
/** Specifies how much worse than the initial test packet that the exchange rate is allowed to get before packets are rejected */
slippage?: number,
/** Pad packets to the maximum size (data field of 32767 bytes). False by default */
enablePadding?: boolean,
/** User-specified connection identifier that was passed into [`generateAddressAndSecret`]{@link Server#generateAddressAndSecret} */
connectionTag?: string,
/** User-specified receipt nonce that was passed into [`generateAddressAndSecret`]{@link Server#generateAddressAndSecret} */
receiptNonce?: Buffer,
/** User-specified receipt secret that was passed into [`generateAddressAndSecret`]{@link Server#generateAddressAndSecret} */
receiptSecret?: Buffer,
/** Maximum number of streams the other entity can have open at once. Defaults to 10 */
maxRemoteStreams?: number,
/** Number of bytes each connection can have in the buffer. Defaults to 65534 */
connectionBufferSize?: number,
/** Minimum Precision to use when determining the exchange rate */
minExchangeRatePrecision?: number,
/** Inactivity timeout (milliseconds) */
idleTimeout?: number,
/**
* Fixed maximum packet amount. When set, the connection's maximum packet amount
* is the minimum of this setting and the path's discovered maximum amount.
*/
maximumPacketAmount?: string,
/**
* Fixed exchange rate. When set, the connection skips the packet volley step.
* Note that the minimum acceptable exchange rate is usually slightly lower than
* this (depending on the connection's slippage).
*
* This option should usually be used in concert with `ConnectionOpts.maximumPacketAmount`.
*/
exchangeRate?: number,
/**
* Returns the expiry to use for an ILP Prepare to the specified destination.
* When omitted, use a timeout of 30 seconds.
*/
getExpiry?: (destination: string) => Date,
/**
* Callback for the consumer to perform accounting and choose to fulfill an incoming ILP Prepare,
* given the amount received, a unique identifier for the packet, and `connectionTag`.
*
* If the returned Promise is resolved, the ILP Prepare will be fulfilled; if it is rejected,
* the ILP Prepare will be rejected. The ILP Fulfill will be immediately sent back after
* the Promise is resolved.
*
* Note: a misbehaving sender can trigger duplicate packetIds, which should be ignored and rejected.
*/
shouldFulfill?: (packetAmount: Long, packetId: Buffer, connectionTag?: string) => Promise<void>,
}
export interface BuildConnectionOpts extends ConnectionOpts {
sourceAccount: string,
assetCode: string,
assetScale: number,
isServer: boolean,
/** Shared secret generated by the server with [`generateAddressAndSecret`]{@link Server#generateAddressAndSecret} */
sharedSecret: Buffer
}
interface NewConnectionOpts extends BuildConnectionOpts {
pskKey: Buffer,
fulfillmentKey: Buffer,
}
export class ConnectionError extends Error {
streamErrorCode: ErrorCode
constructor (message: string, streamErrorCode?: ErrorCode) {
super(message)
this.streamErrorCode = streamErrorCode || ErrorCode.InternalError
}
}
enum RemoteState {
Init,
Connected,
Closed
}
function defaultGetExpiry (): Date {
return new Date(Date.now() + DEFAULT_PACKET_TIMEOUT)
}
/**
* Class representing the connection between a [`Client`]{@link createConnection} and a [`Server`]{@link Server}.
* A single connection can be used to send or receive on [Streams]{@link DataAndMoneyStream}.
*
* Streams are created using the [`createStream`]{@link createStream} method.
* The `'stream'` event will be emitted whenever a new incoming stream is opened by the other party.
*/
export class Connection extends EventEmitter {
/** Application identifier for a certain connection */
readonly connectionTag?: string
protected readonly _receiptNonce?: Buffer
protected readonly _receiptSecret?: Buffer
protected connectionId: string
protected plugin: Plugin
protected _sourceAccount: string
protected _sourceAssetCode: string
protected _sourceAssetScale: number
protected _destinationAccount?: string
protected _destinationAssetCode?: string
protected _destinationAssetScale?: number
protected sharedSecret: Buffer
protected _pskKey: Buffer
protected _fulfillmentKey: Buffer
protected isServer: boolean
protected slippage: Rational
protected allowableReceiveExtra: Rational
protected enablePadding: boolean
protected maxBufferedData: number
protected idleTimeout: number
protected lastActive: Date
protected idleTimer: NodeJS.Timer
protected rateRetryTimer: StoppableTimeout = new StoppableTimeout()
protected nextPacketSequence: number
protected streams: Map<number, DataAndMoneyStream>
protected closedStreams: Set<number>
protected nextStreamId: number
protected maxStreamId: number
protected log: any
protected sending: boolean
protected looping: boolean = false // whether there is a running send-loop
protected congestion: CongestionController
protected minExchangeRatePrecision: number
protected connected: boolean
// Set to `true`:
// - Until `'connect'` event.
// - When a `ConnectionCloseFrame` is received.
// - When `end()` is called, but before sending the `ConnectionCloseFrame` to remote.
protected closed: boolean
// Set to `true` when either `end()` or `destroy()` has finished.
protected done: boolean = false
protected exchangeRate?: Rational
protected retryDelay: number
protected queuedFrames: Frame[]
protected remoteState: RemoteState = RemoteState.Init
protected remoteMaxStreamId: number
protected remoteKnowsOurAccount: boolean
// TODO use longs for byte offsets
protected remoteMaxOffset: number
protected _incomingHold: Long
protected _totalReceived: Long
protected _totalSent: Long
protected _totalDelivered: Long
protected _lastPacketExchangeRate: Rational
protected getExpiry: (destination: string) => Date
protected shouldFulfill?: (packetAmount: Long, packetId: Buffer, connectionTag?: string) => Promise<void>
constructor (opts: NewConnectionOpts) {
super()
// Use the same connectionId for logging on both client & server
const lastAddressSegment = opts.destinationAccount ? opts.destinationAccount.split('.').slice(-1)[0] : undefined
this.connectionId = (opts.connectionId || lastAddressSegment || uuid()).replace(/[-_]/g, '').slice(0, 8)
this.plugin = opts.plugin
this._sourceAccount = opts.sourceAccount
this._sourceAssetCode = opts.assetCode
this._sourceAssetScale = opts.assetScale
this._destinationAccount = opts.destinationAccount
this.sharedSecret = opts.sharedSecret
this.isServer = opts.isServer
this._pskKey = opts.pskKey
this._fulfillmentKey = opts.fulfillmentKey
this.slippage = Rational.fromNumber(opts.slippage === undefined ? 0.01 : opts.slippage, true)
if (this.slippage.greaterThanOne()) {
throw new Error('slippage must be less than one')
}
this.allowableReceiveExtra = Rational.fromNumber(1.01, true)
this.enablePadding = !!opts.enablePadding
this.connectionTag = opts.connectionTag
if (!opts.receiptNonce !== !opts.receiptSecret) {
throw new Error('receiptNonce and receiptSecret must accompany each other')
}
this._receiptNonce = opts.receiptNonce
this._receiptSecret = opts.receiptSecret
this.maxStreamId = 2 * (opts.maxRemoteStreams || DEFAULT_MAX_REMOTE_STREAMS)
this.maxBufferedData = opts.connectionBufferSize || MAX_DATA_SIZE * 2
this.minExchangeRatePrecision = opts.minExchangeRatePrecision || DEFAULT_MINIMUM_EXCHANGE_RATE_PRECISION
this.exchangeRate = opts.exchangeRate === undefined
? undefined
: Rational.fromNumber(opts.exchangeRate, true)
this.getExpiry = opts.getExpiry || defaultGetExpiry
this.shouldFulfill = opts.shouldFulfill
this.idleTimeout = opts.idleTimeout || DEFAULT_IDLE_TIMEOUT
this.lastActive = new Date()
this.nextPacketSequence = 1
// TODO should streams be a Map or just an object?
this.streams = new Map()
this.closedStreams = new Set()
this.nextStreamId = (this.isServer ? 2 : 1)
this.log = createLogger(`ilp-protocol-stream:${this.isServer ? 'Server' : 'Client'}:Connection:${this.connectionId}`)
this.sending = false
this.connected = false
this.closed = true
this.queuedFrames = []
this.congestion = new CongestionController({
maximumPacketAmount: opts.maximumPacketAmount === undefined
? undefined
: Long.fromString(opts.maximumPacketAmount, true)
})
this.retryDelay = RETRY_DELAY_START
this.remoteKnowsOurAccount = this.isServer
this.remoteMaxStreamId = DEFAULT_MAX_REMOTE_STREAMS * 2
this.remoteMaxOffset = this.maxBufferedData
this._incomingHold = Long.UZERO
this._totalReceived = Long.UZERO
this._totalSent = Long.UZERO
this._totalDelivered = Long.UZERO
this._lastPacketExchangeRate = Rational.UZERO
this.once('connect', () => this.startIdleTimer())
}
/**
* @private
*/
static async build (opts: BuildConnectionOpts): Promise<Connection> {
const pskKey = await cryptoHelper.generatePskEncryptionKey(opts.sharedSecret)
const fulfillmentKey = await cryptoHelper.generateFulfillmentKey(opts.sharedSecret)
return new Connection(Object.assign({ pskKey, fulfillmentKey }, opts))
}
/**
* New incoming stream event
* @event stream
* @type {DataAndMoneyStream}
*/
/**
* Start sending or receiving.
* @fires stream
*/
async connect (): Promise<void> {
if (!this.closed) {
return Promise.resolve()
}
/* tslint:disable-next-line:no-floating-promises */
this.startSendLoop()
await new Promise((resolve, reject) => {
const connectHandler = () => {
cleanup()
resolve()
}
const closeHandler = () => {
cleanup()
this.stopTimers()
reject(new Error('Connection was closed before it was connected'))
}
const errorHandler = (error?: Error) => {
cleanup()
this.stopTimers()
reject(new Error(`Error connecting${error ? ': ' + error.message : ''}`))
}
this.once('connect', connectHandler)
this.once('error', errorHandler)
this.once('close', closeHandler)
this.once('end', closeHandler)
const self = this
function cleanup () {
self.removeListener('connect', connectHandler)
self.removeListener('error', errorHandler)
self.removeListener('close', closeHandler)
self.removeListener('end', closeHandler)
}
})
this.closed = false
}
/**
* Close the connection when all streams have finished sending their money and data
*/
// TODO should this be sync or async?
async end (): Promise<void> {
this.log.info('closing connection')
// Create Promises on each stream that resolve on the 'end' event so
// we can wait for them all to be completed before closing the connection
let streamEndPromises: Promise<any>[] = []
for (let [_, stream] of this.streams) {
if (stream.isOpen()) {
streamEndPromises.push(new Promise((resolve, reject) => {
stream.on('end', resolve)
}))
stream.end()
}
}
await new Promise((resolve, reject) => {
this.once('_send_loop_finished', resolve)
this.once('error', reject)
/* tslint:disable-next-line:no-floating-promises */
this.startSendLoop()
})
// Wait for the send loop to finish & all the streams to end
// before marking the connection as closed so the streams
// can finish sending data or money.
await Promise.all(streamEndPromises)
this.closed = true
await this.sendConnectionClose()
this.safeEmit('end')
this.safeEmit('close')
this.stopTimers()
}
/**
* Immediately close the connection and all streams.
*
* This function must *never* return a rejection, since `startSendLoop` is run
* as a floating promise and returns `destroy()` on errors, so the rejection
* would be unhandled.
*/
// TODO should this be sync or async?
async destroy (err?: Error): Promise<void> {
this.log.error('destroying connection with error:', err)
// Don't destroy the connection (most importantly: don't emit "close" & "error"
// if the connection has already done so, since it will confuse clients.
if (this.done) return
if (err) {
this.safeEmit('error', err)
}
// Create Promises on each stream that resolve on the 'close' event so
// we can wait for them all to be completed before closing the connection
let streamClosePromises: Promise<any>[] = []
for (let [_, stream] of this.streams) {
streamClosePromises.push(new Promise((resolve, reject) => {
stream.on('close', resolve)
}))
// TODO should we pass the error to each stream?
stream.destroy()
}
// Send an error to ensure that the other side doesn't get a NoError.
await this.sendConnectionClose(err || new ConnectionError('Connection destroyed', ErrorCode.ApplicationError))
// wait for all the streams to be closed before emitting the connection 'close'
await Promise.all(streamClosePromises)
this.safeEmit('close')
this.stopTimers()
}
/**
* Returns a new bidirectional [`DataAndMoneyStream`]{@link DataAndMoneyStream}
*/
createStream (): DataAndMoneyStream {
// Make sure we don't open more streams than the remote will allow
if (this.remoteMaxStreamId < this.nextStreamId) {
this.log.debug('cannot create another stream. nextStreamId: %d, remote maxStreamId: %d', this.nextStreamId, this.remoteMaxStreamId)
this.queuedFrames.push(new ConnectionStreamIdBlockedFrame(this.nextStreamId))
throw new Error(`Creating another stream would exceed the remote connection's maximum number of open streams`)
}
// TODO should this inform the other side?
const stream = new DataAndMoneyStream({
id: this.nextStreamId,
isServer: this.isServer,
connectionId: this.connectionId
})
this.streams.set(this.nextStreamId, stream)
this.log.debug('created stream: %d', this.nextStreamId)
this.nextStreamId += 2
stream.on('_maybe_start_send_loop', this.startSendLoop.bind(this))
stream.once('close', () => this.removeStreamRecord(stream))
return stream
}
/**
* ILP Address of the remote party to this connection.
*/
get destinationAccount (): string | undefined {
return this._destinationAccount
}
/**
* Scale of the asset used by the remote party to this connection
*/
get destinationAssetScale (): number | undefined {
return this._destinationAssetScale
}
/**
* Code of the asset used by the remote party to this connection
*/
get destinationAssetCode (): string | undefined {
return this._destinationAssetCode
}
/**
* ILP Address of the plugin passed to this connection.
*/
get sourceAccount (): string {
return this._sourceAccount
}
/**
* Scale of the asset used by the plugin passed to this connection
*/
get sourceAssetScale (): number {
return this._sourceAssetScale
}
/**
* Code of the asset used by the plugin passed to this connection
*/
get sourceAssetCode (): string {
return this._sourceAssetCode
}
/**
* Connections minimum exchange rate with slippage included, if not set '0' is returned.
*/
get minimumAcceptableExchangeRate (): string {
if (this.exchangeRate) {
const minimumExchangeWithSlippage = this.exchangeRate
.multiplyByRational(this.slippage.complement())
return minimumExchangeWithSlippage.toString()
}
return '0'
}
/**
* Calculates the last exchange rate based on last packet successfully sent.
*/
get lastPacketExchangeRate (): string {
return this._lastPacketExchangeRate.toString()
}
/**
* Total delivered so far, denominated in the connection plugin's units.
*/
get totalDelivered (): string {
return this._totalDelivered.toString()
}
/**
* Total sent so far, denominated in the connection plugin's units.
*/
get totalSent (): string {
return this._totalSent.toString()
}
/**
* Total received so far by the local side, denominated in the connection plugin's units.
*/
get totalReceived (): string {
return this._totalReceived.toString()
}
/**
* (Internal) Handle incoming ILP Prepare packets.
* This will automatically fulfill all valid and expected Prepare packets, or
* defer to custom application logic using the `shouldFulfill` callback, if provided.
* It passes the incoming money and/or data to the relevant streams.
* @private
*/
async handlePrepare (prepare: IlpPacket.IlpPrepare): Promise<IlpPacket.IlpFulfill> {
// Parse packet
let requestPacket: Packet
try {
requestPacket = await Packet.decryptAndDeserialize(this._pskKey, prepare.data)
} catch (err) {
this.log.error('error parsing frames:', err)
throw new IlpPacket.Errors.UnexpectedPaymentError('')
}
this.log.trace('handling packet:', JSON.stringify(requestPacket))
if (requestPacket.ilpPacketType.valueOf() !== IlpPacket.Type.TYPE_ILP_PREPARE) {
this.log.error('prepare packet contains a frame that says it should be something other than a prepare: %d', requestPacket.ilpPacketType)
throw new IlpPacket.Errors.UnexpectedPaymentError('')
}
this.bumpIdle()
let responseFrames: Frame[] = []
// Tell peer how much data connection can receive
responseFrames.push(new ConnectionMaxDataFrame(this.getIncomingOffsets().maxAcceptable))
const throwFinalApplicationError = async () => {
responseFrames = responseFrames.concat(this.queuedFrames)
this.queuedFrames = []
const responsePacket = new Packet(requestPacket.sequence, IlpPacketType.Reject, prepare.amount, responseFrames)
this.log.trace('rejecting packet %s: %j', requestPacket.sequence, responsePacket)
throw new IlpPacket.Errors.FinalApplicationError('', await responsePacket.serializeAndEncrypt(this._pskKey, (this.enablePadding ? MAX_DATA_SIZE : undefined)))
}
// Handle new streams
for (let frame of requestPacket.frames) {
if (frame.type === FrameType.StreamMoney
|| frame.type === FrameType.StreamData
// TODO should frames that set the max values open the stream?
|| frame.type === FrameType.StreamMaxMoney
|| frame.type === FrameType.StreamMaxData) {
const streamId = frame.streamId.toNumber()
// Check if the stream was already closed
if (this.closedStreams.has(streamId)) {
this.log.trace('got packet with frame for stream %d, which was already closed', streamId)
// Don't bother sending an error frame back unless they've actually sent money or data
if (frame.type !== FrameType.StreamMoney && frame.type !== FrameType.StreamData) {
continue
}
// Respond with a StreamClose frame (unless there is already one queued)
const testStreamClose = (frame: Frame): boolean => {
return frame.type === FrameType.StreamClose
&& frame.streamId.equals(streamId)
}
const includesStreamClose
= responseFrames.find(testStreamClose)
|| this.queuedFrames.find(testStreamClose)
if (!includesStreamClose) {
responseFrames.push(new StreamCloseFrame(streamId, ErrorCode.StreamStateError, 'Stream is already closed'))
}
await throwFinalApplicationError()
}
try {
// Note this will throw if the stream was already closed
this.handleNewStream(frame.streamId.toNumber())
} catch (err) {
this.log.debug('error handling new stream %s: %s', frame.streamId, err && err.message)
await throwFinalApplicationError()
}
}
}
// TODO don't throw errors in expected cases -- they are slower than just returning a value
try {
this.handleControlFrames(requestPacket.frames)
} catch (err) {
this.log.debug('error handling frames:', err && err.message)
await throwFinalApplicationError()
}
// TODO keep a running total of the offsets so we don't need to recalculate each time
const incomingOffsets = this.getIncomingOffsets()
if (incomingOffsets.max > incomingOffsets.maxAcceptable) {
/* tslint:disable-next-line:no-floating-promises */
this.destroy(new ConnectionError(`Exceeded flow control limits. Max connection byte offset: ${incomingOffsets.maxAcceptable}, received: ${incomingOffsets.max}`, ErrorCode.FlowControlError))
await throwFinalApplicationError()
}
const incomingAmount = Long.fromString(prepare.amount, true)
if (requestPacket.prepareAmount.greaterThan(incomingAmount)) {
this.log.debug('received less than minimum destination amount. actual: %s, expected: %s', prepare.amount, requestPacket.prepareAmount)
await throwFinalApplicationError()
}
// Ensure we can generate correct fulfillment
const fulfillment = await cryptoHelper.generateFulfillment(this._fulfillmentKey, prepare.data)
const generatedCondition = await cryptoHelper.hash(fulfillment)
if (!generatedCondition.equals(prepare.executionCondition)) {
this.log.debug('got unfulfillable prepare for amount: %s. generated condition: %h, prepare condition: %h', prepare.amount, generatedCondition, prepare.executionCondition)
await throwFinalApplicationError()
}
// Determine amount to receive on each frame
const amountsToReceive: { stream: DataAndMoneyStream, amount: Long }[] = []
const totalMoneyShares = requestPacket.frames.reduce((sum: Long, frame: Frame) => {
if (frame instanceof StreamMoneyFrame) {
const result = checkedAdd(sum, frame.shares)
if (result.overflow) throw new Error('Total shares exceeded MaxUint64')
return result.sum
}
return sum
}, Long.UZERO)
for (let frame of requestPacket.frames) {
if (!(frame instanceof StreamMoneyFrame)) {
continue
}
const streamId = frame.streamId.toNumber()
// TODO make sure we don't lose any because of rounding issues
const streamAmount = multiplyDivideFloor(
incomingAmount, frame.shares, totalMoneyShares)
const stream = this.streams.get(streamId)!
amountsToReceive.push({
stream,
amount: streamAmount
})
// Ensure that this amount isn't more than the stream can receive
const maxStreamCanReceive = this.allowableReceiveExtra
.multiplyByLongCeil(stream._getAmountStreamCanReceive())
if (maxStreamCanReceive.lessThan(streamAmount)) {
// TODO should this be distributed to other streams if it can be?
this.log.debug('peer sent too much for stream: %d. got: %s, max receivable: %s', streamId, streamAmount, maxStreamCanReceive)
// Tell peer how much the streams they sent for can receive
responseFrames.push(new StreamMaxMoneyFrame(streamId, stream.receiveMax, stream.totalReceived))
// TODO include error frame
await throwFinalApplicationError()
}
// Reject the packet if any of the streams is already closed
if (!stream.isOpen()) {
this.log.debug('peer sent money for stream that was already closed: %d', streamId)
responseFrames.push(new StreamCloseFrame(streamId, ErrorCode.StreamStateError, 'Stream is already closed'))
await throwFinalApplicationError()
}
}
this.addIncomingHold(incomingAmount)
// Allow consumer to choose to fulfill each packet and/or perform other logic before fulfilling
if (this.shouldFulfill && incomingAmount.greaterThan(0)) {
const packetId = await cryptoHelper.generateIncomingPacketId(this.sharedSecret, requestPacket.sequence)
await this.shouldFulfill(incomingAmount, packetId, this.connectionTag).catch(async err => {
this.removeIncomingHold(incomingAmount)
this.log.debug('application declined to fulfill packet %s:', requestPacket.sequence, err)
await throwFinalApplicationError()
})
}
// Add incoming amounts to each stream
const totalsReceived: Map<number, string> = new Map()
for (let { stream, amount } of amountsToReceive) {
stream._addToIncoming(amount, prepare)
totalsReceived.set(stream.id, stream.totalReceived)
}
// Tell peer about closed streams and how much each stream can receive
if (!this.closed && this.remoteState !== RemoteState.Closed) {
for (let [_, stream] of this.streams) {
if (!stream.isOpen() && !stream._remoteClosed) {
this.log.trace('telling other side that stream %d is closed', stream.id)
if (stream._errorMessage) {
responseFrames.push(new StreamCloseFrame(stream.id, ErrorCode.ApplicationError, stream._errorMessage))
} else {
responseFrames.push(new StreamCloseFrame(stream.id, ErrorCode.NoError, ''))
}
// TODO confirm that they get this
stream._remoteClosed = true
} else {
this.log.trace('telling other side that stream %d can receive %s', stream.id, stream.receiveMax)
responseFrames.push(new StreamMaxMoneyFrame(stream.id, stream.receiveMax, stream.totalReceived))
// TODO only send these frames when we need to
responseFrames.push(new StreamMaxDataFrame(stream.id, stream._getIncomingOffsets().maxAcceptable))
}
}
}
// Add receipt frame(s)
if (this._receiptNonce && this._receiptSecret) {
for (let [streamId, totalReceived] of totalsReceived) {
responseFrames.push(new StreamReceiptFrame(streamId, createReceipt({
nonce: this._receiptNonce,
streamId,
totalReceived,
secret: this._receiptSecret
})))
}
}
// TODO make sure the queued frames aren't too big
responseFrames = responseFrames.concat(this.queuedFrames)
this.queuedFrames = []
// Return fulfillment and response packet
const responsePacket = new Packet(requestPacket.sequence, IlpPacketType.Fulfill, incomingAmount, responseFrames)
this.removeIncomingHold(incomingAmount)
this.addTotalReceived(incomingAmount)
this.log.trace('fulfilling prepare with fulfillment: %h and response packet: %j', fulfillment, responsePacket)
return {
fulfillment,
data: await responsePacket.serializeAndEncrypt(this._pskKey, (this.enablePadding ? MAX_DATA_SIZE : undefined))
}
}
/**
* Parse the frames from the incoming packet and apply all effects
* except for passing money to the streams
*/
protected handleControlFrames (frames: Frame[]): void {
for (let frame of frames) {
let stream
switch (frame.type) {
case FrameType.ConnectionNewAddress:
this.log.trace('peer notified us of their account: %s', frame.sourceAccount)
// Tell the other side our max stream id and asset details
this.queuedFrames.push(
new ConnectionMaxStreamIdFrame(this.maxStreamId),
new ConnectionAssetDetailsFrame(this.sourceAssetCode, this.sourceAssetScale)
)
const firstConnection = this._destinationAccount === undefined
this._destinationAccount = frame.sourceAccount
if (firstConnection) {
this.closed = false
this.log.info('connected')
this.safeEmit('connect')
}
// TODO reset the exchange rate and send a test packet to make sure they haven't spoofed the address
break
case FrameType.ConnectionAssetDetails:
this.log.trace('peer notified us of their asset details: code=%s, scale=%d', frame.sourceAssetCode, frame.sourceAssetScale)
this._destinationAssetCode = frame.sourceAssetCode
this._destinationAssetScale = frame.sourceAssetScale
break
case FrameType.ConnectionClose:
// TODO end the connection in some other way
this.sending = false
this.closed = true
this.remoteState = RemoteState.Closed
if (frame.errorCode === ErrorCode.NoError) {
this.log.info('remote closed connection')
/* tslint:disable-next-line:no-floating-promises */
this.end().catch((err) => {
this.log.warn('close failed with error=%s', err)
return this.destroy()
})
} else {
this.log.error('remote connection error. code: %s, message: %s', ErrorCode[frame.errorCode], frame.errorMessage)
/* tslint:disable-next-line:no-floating-promises */
this.destroy(new Error(`Remote connection error. Code: ${ErrorCode[frame.errorCode]}, message: ${frame.errorMessage}`))
}
break
case FrameType.ConnectionMaxData:
const outgoingOffsets = this.getOutgoingOffsets()
this.log.trace('remote connection max byte offset is: %s, we\'ve sent: %d, we want to send up to: %d', frame.maxOffset, outgoingOffsets.currentOffset, outgoingOffsets.maxOffset)
if (frame.maxOffset.notEquals(this.maxBufferedData)) {
this.remoteMaxOffset = Math.max(frame.maxOffset.toNumber(), this.remoteMaxOffset)
} else {
// We assumed their size was 64kb but it turned out to be less
this.remoteMaxOffset = frame.maxOffset.toNumber()
}
break
case FrameType.ConnectionDataBlocked:
this.log.trace('remote wants to send more data but we are blocking them. current max incoming offset: %d, remote max offset: %s', this.getIncomingOffsets(), frame.maxOffset)
break
case FrameType.ConnectionMaxStreamId:
// TODO make sure the number isn't lowered
this.log.trace('remote set max stream id to %s', frame.maxStreamId)
this.remoteMaxStreamId = frame.maxStreamId.toNumber()
break
case FrameType.ConnectionStreamIdBlocked:
this.log.trace('remote wants to open more streams but we are blocking them')
break
case FrameType.StreamClose:
this.handleStreamClose(frame)
break
case FrameType.StreamMaxMoney:
this.log.trace('peer told us that stream %s can receive up to: %s and has received: %s so far', frame.streamId, frame.receiveMax, frame.totalReceived)
stream = this.streams.get(frame.streamId.toNumber())
if (!stream) {
break
}
stream._remoteReceived = maxLong(stream._remoteReceived, frame.totalReceived)
if (stream._remoteReceiveMax.notEquals(Long.MAX_UNSIGNED_VALUE)) {
stream._remoteReceiveMax = maxLong(stream._remoteReceiveMax, frame.receiveMax)
} else {
stream._remoteReceiveMax = frame.receiveMax
}
if (stream._remoteReceiveMax.greaterThan(stream._remoteReceived)
&& stream._getAmountAvailableToSend().greaterThan(0)) {
/* tslint:disable-next-line:no-floating-promises */
this.startSendLoop()
}
break
case FrameType.StreamMoneyBlocked:
this.log.debug('peer told us that they want to send more money on stream %s but we are blocking them. they have sent: %s so far and want to send: %s', frame.streamId, frame.totalSent, frame.sendMax)
break
case FrameType.StreamData:
this.log.trace('got data for stream %s', frame.streamId)
stream = this.streams.get(frame.streamId.toNumber())
if (!stream) {
break
}
stream._pushIncomingData(frame.data, frame.offset.toNumber())
// Make sure the peer hasn't exceeded the flow control limits
const incomingOffsets = stream._getIncomingOffsets()
if (incomingOffsets.max > incomingOffsets.maxAcceptable) {
/* tslint:disable-next-line:no-floating-promises */
this.destroy(new ConnectionError(`Exceeded flow control limits. Stream ${stream.id} can accept up to offset: ${incomingOffsets.maxAcceptable} but got bytes up to offset: ${incomingOffsets.max}`, ErrorCode.FlowControlError))
}
break
case FrameType.StreamMaxData:
stream = this.streams.get(frame.streamId.toNumber())
if (!stream) {
break
}
const oldOffset = stream._remoteMaxOffset
const newOffset = frame.maxOffset.toNumber()
if (newOffset > oldOffset) {
this.log.trace('peer told us that stream %s can receive up to byte offset: %s (we\'ve sent up to offset: %d)', frame.streamId, frame.maxOffset, stream._getOutgoingOffsets().current)
stream._remoteMaxOffset = newOffset
/* tslint:disable-next-line:no-floating-promises */
this.startSendLoop()
} else {
this.log.trace('peer told us that stream %s can receive up to byte offset: %d; ignoring new offset: %d', frame.streamId, oldOffset, newOffset)
}
break
case FrameType.StreamDataBlocked:
stream = this.streams.get(frame.streamId.toNumber())
if (!stream) {
break
}
this.log.debug('peer told us that stream %s is blocked. they want to send up to offset: %s, but we are only allowing up to: %d', frame.streamId, frame.maxOffset, stream._getIncomingOffsets().maxAcceptable)
break
default:
continue
}
}
}
/**
* Ensure that the new stream is valid and does not exceed our limits
* and if it looks good, emit the 'stream' event
*/
protected handleNewStream (streamId: number): void {
if (this.streams.has(streamId) || this.closedStreams.has(streamId)) {
return
}
// Validate stream ID
if (this.isServer && streamId % 2 === 0) {
this.log.error('got invalid stream ID %d from peer (should be odd)', streamId)
this.queuedFrames.push(new ConnectionCloseFrame(ErrorCode.ProtocolViolation, `Invalid Stream ID: ${streamId}. Client-initiated streams must have odd-numbered IDs`))
// TODO this should probably call this.destroy
const err = new Error(`Invalid Stream ID: ${streamId}. Client-initiated streams must have odd-numbered IDs`)
this.safeEmit('error', err)
throw err
} else if (!this.isServer && streamId % 2 === 1) {
this.log.error('got invalid stream ID %d from peer (should be even)', streamId)
this.queuedFrames.push(new ConnectionCloseFrame(ErrorCode.ProtocolViolation, `Invalid Stream ID: ${streamId}. Server-initiated streams must have even-numbered IDs`))
const err = new Error(`Invalid Stream ID: ${streamId}. Server-initiated streams must have even-numbered IDs`)
this.safeEmit('error', err)
throw err
}
// Make sure there aren't too many open streams
if (streamId > this.maxStreamId) {
this.log.debug('peer opened too many streams. got stream: %d, but max stream id is: %d. closing connection', streamId, this.maxStreamId)
this.queuedFrames.push(new ConnectionCloseFrame(ErrorCode.StreamIdError, `Maximum number of open streams exceeded. Got stream: ${streamId}, current max stream ID: ${this.maxStreamId}`))
const err = new Error(`Maximum number of open streams exceeded. Got stream: ${streamId}, current max stream ID: ${this.maxStreamId}`)
this.safeEmit('error', err)
throw err
}
// Let the other side know if they're getting close to the number of streams
if (this.maxStreamId * .75 < streamId) {
this.log.trace('informing peer that our max stream id is: %d', this.maxStreamId)
this.queuedFrames.push(new ConnectionMaxStreamIdFrame(this.maxStreamId))
}
this.log.info('got new stream: %d', streamId)
const stream = new DataAndMoneyStream({
id: streamId,
isServer: this.isServer,
connectionId: this.connectionId
})
this.streams.set(streamId, stream)
stream.on('_maybe_start_send_loop', () => this.startSendLoop())
stream.once('close', () => this.removeStreamRecord(stream))
this.safeEmit('stream', stream)
}
/**
* Mark the stream as closed
*/
protected handleStreamClose (frame: StreamCloseFrame) {
const streamId = frame.streamId.toNumber()
const stream = this.streams.get(streamId)
if (!stream) {
this.log.error('remote error on stream %d, but we don\'t have a record of that stream', streamId)
return
}
if (!stream.isOpen() || stream._remoteSentEnd) {
return
}
this.log.error('peer closed stream %d with error code: %s and message: %s', stream.id, ErrorCode[frame.errorCode], frame.errorMessage)
// TODO should we confirm with the other side that we closed it?
stream._sentEnd = true
let err
if (frame.errorMessage) {
err = new Error(frame.errorMessage)
err.name = ErrorCode[frame.errorCode]
}
stream._remoteEnded(err)
// TODO make sure we don't send more than one of these frames per packet
this.maxStreamId += 2
this.log.trace('raising maxStreamId to %d', this.maxStreamId)
this.queuedFrames.push(new ConnectionMaxStreamIdFrame(this.maxStreamId))
// Start send loop to make sure this frame is sent
/* tslint:disable-next-line:no-floating-promises */
this.startSendLoop()
}
/**
* (Internal) Start sending packets with money and/or data, as necessary.
* @private
*/
protected async startSendLoop () {
if (this.looping) {
// `sending` may be `false`, but the loop has not yet exited.
// Setting `true` ensures that it will try to loop at least once more before stopping,
// ensuring whatever changes triggered this `startSendLoop` call are sent.
this.sending = true
return
}
if (this.remoteState === RemoteState.Closed) {
this.log.debug('remote connection is already closed, not starting another loop')
this.safeEmit('_send_loop_finished')
return
}
if (!this._destinationAccount) {
this.log.debug('not sending because we do not know the client\'s address')
this.safeEmit('_send_loop_finished')
return
}
this.looping = true
this.sending = true
this.log.debug('starting send loop')