-
Notifications
You must be signed in to change notification settings - Fork 25
feat(BundleDataClient): Support refunds for pre-fills/slow-fill-requests and duplicate deposits #835
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat(BundleDataClient): Support refunds for pre-fills/slow-fill-requests and duplicate deposits #835
Changes from 11 commits
Commits
Show all changes
118 commits
Select commit
Hold shift + click to select a range
2b647e5
feat(BundleDataClient): Support refunds for pre-fills and fills for p…
nicholaspai 5d81719
re-use v3Relayhashes to get all deposits
nicholaspai b6053c7
Update src/clients/BundleDataClient/BundleDataClient.ts
nicholaspai f91d4dd
Update BundleDataClient.ts
nicholaspai 20325f1
Update BundleDataClient.ts
nicholaspai 8ed54b8
Update BundleDataClient.ts
nicholaspai d3be312
feat: Add findFillEvent utility function (#836)
nicholaspai 23d9a6e
Update BundleDataClient.ts
nicholaspai 80e5b1c
feat(BundleDataClient): Support duplicate expired deposit refunds
nicholaspai c608499
Handle duplicate deposits
nicholaspai 81f3e51
Merge branch 'pre-fills' into duplicate-deposit-refunds
nicholaspai 9cbded7
Update SpokePoolClient.ts
nicholaspai 828c55e
Fix duplicate deposit logic
nicholaspai 45e75eb
Add caveats about duplicate deposits
nicholaspai c9184e4
Update BundleDataClient.ts
nicholaspai c3bb4bb
Merge branch 'pre-fills' into duplicate-deposit-refunds
nicholaspai 886a5e0
Handle expired deposits better
nicholaspai a7b5f36
Merge branch 'pre-fills' into duplicate-deposit-refunds
nicholaspai 1fc293a
Update BundleDataClient.ts
nicholaspai 6569e60
Update BundleDataClient.ts
nicholaspai 774b88a
Merge branch 'pre-fills' into duplicate-deposit-refunds
nicholaspai 09d55f8
Update BundleDataClient.ts
nicholaspai 29be548
Update BundleDataClient.ts
nicholaspai 75ba384
Fix expired deposit loigc
nicholaspai f411d4c
Merge branch 'pre-fills' into duplicate-deposit-refunds
nicholaspai 130992e
Update BundleDataClient.ts
nicholaspai 15ebfea
Update BundleDataClient.ts
nicholaspai a798405
Update BundleDataClient.ts
nicholaspai 768b5d6
fix pre slow fill request handling
nicholaspai 945defd
Update BundleDataClient.ts
nicholaspai 4d34e0d
Update BundleDataClient.ts
nicholaspai d8a1881
Update package.json
nicholaspai 719c29c
adjust for empty message hash
nicholaspai b4d85d7
add isZeroValueFillOrSlowFillRequest
nicholaspai 6247de9
Merge branch 'master' into pre-fills
nicholaspai 70bf908
fix
nicholaspai 2366769
Update package.json
nicholaspai e6f4996
use isMessageEmpty in isFillOrSlowFillRequestMessageEmpty
nicholaspai 2d83142
Update SpokePoolClient.ValidateFill.ts
nicholaspai dde1130
Refactor and change up conditionals for marginal speedups
nicholaspai d18e6a6
Update BundleDataClient.ts
nicholaspai f32cdb4
Merge branch 'master' into pre-fills
nicholaspai 45441a1
Update SpokePoolClient.fills.ts
nicholaspai 461db1c
refactor
nicholaspai ebcaf16
Update package.json
nicholaspai 38a6fc7
merge
nicholaspai 1924d76
Update package.json
nicholaspai bd9bd0b
Update MockSpokePoolClient.ts
nicholaspai 2e3f0be
Update package.json
nicholaspai 7b3170d
Update BundleDataClient.ts
nicholaspai 36f0836
Gatekeep behind version bump
nicholaspai de4f3f8
Update BundleDataClient.ts
nicholaspai 9488ecf
fix
nicholaspai a6b236d
fix toggle
nicholaspai 4f0f42e
meerge
nicholaspai 5a32e81
Update BundleDataClient.ts
nicholaspai 1ec42b5
Add verifyFill check to pre-fillr efund
nicholaspai 7bdc4a1
Update src/clients/BundleDataClient/BundleDataClient.ts
nicholaspai 5bf0ad2
Update BundleDataClient.ts
nicholaspai 68f50a4
Update package.json
nicholaspai 57a2a9f
fix(BundleDataClient): Make sure bundle block timestamps have no gaps
nicholaspai 01343f4
Update package.json
nicholaspai b98d74a
Merge branch 'bundle-block-timestamps' into pre-fills
nicholaspai 6ad7121
Revert "Merge branch 'bundle-block-timestamps' into pre-fills"
nicholaspai 6001a8c
Update package.json
nicholaspai 6e90289
Update BundleDataClient.ts
nicholaspai 3058a4c
Update BundleDataClient.ts
nicholaspai bc5e5a6
Update BundleDataClient.ts
nicholaspai 89595e9
Update package.json
nicholaspai 5aae5fa
Update BundleDataClient.ts
nicholaspai 7ab59e3
Update package.json
nicholaspai d251e09
fix
nicholaspai 33e6ae2
fix
nicholaspai 929eac8
Update BundleDataClient.ts
nicholaspai a1eb56b
Merge branch 'bundle-block-timestamps' into pre-fills
nicholaspai b19482a
Update package.json
nicholaspai 3153720
Merge branch 'master' into pre-fills
nicholaspai 2349b73
Update package.json
nicholaspai 79f13f2
Add case work
nicholaspai d8b439e
Update package.json
nicholaspai 52090fb
Update BundleDataClient.ts
nicholaspai 709952b
Refund duplicate deposits
nicholaspai 4a520a6
Update package.json
nicholaspai ed8f6d2
Merge branch 'master' into pre-fills
nicholaspai 0b5a43d
Update package.json
nicholaspai 0260489
fix
nicholaspai 8aa3835
use ZERO_BYTES
nicholaspai dc6d482
Remove isSlowFill check
nicholaspai 9ebd0f2
Update package.json
nicholaspai d1eafe5
Revert "Remove isSlowFill check"
nicholaspai 3479dae
Re-add slow fill check and refund to depositor
nicholaspai 6ffd1a0
Update BundleDataClient.ts
nicholaspai 5ada1fb
Remove duplicate deposit refunds and revert back to refunding pre-fills
nicholaspai e1d60a6
wip
nicholaspai f0544d4
fix
nicholaspai 3fc8262
Update package.json
nicholaspai 56ea588
Make sure any time we queryHistoricalDepositForFill we also check the…
nicholaspai b63cf9a
Update package.json
nicholaspai e5cd418
Update BundleDataClient.ts
nicholaspai 5b6eeeb
Update package.json
nicholaspai 6def4c0
Pay duplicate deposits to filler or depositor
nicholaspai de176c7
Update BundleDataClient.ts
nicholaspai 283a8e9
Update package.json
nicholaspai 49560fb
Update BundleDataClient.ts
nicholaspai 84f61ad
Update BundleDataClient.ts
nicholaspai 573d00f
add comments about matching first slow fill leaf
nicholaspai 54f28e4
Update BundleDataClient.ts
nicholaspai f853323
Merge branch 'master' into pre-fills
nicholaspai 87cb051
Add verifyFillRepayment check to prefill loop
nicholaspai 6e6488c
Add extra verifyFillRepayment checks
nicholaspai 7bcc855
lint
nicholaspai 1bcd9c5
Add asserts
nicholaspai 5092f14
Simplify code by removing out of date comments and other things
nicholaspai a2251a0
Add assert
nicholaspai cc9ebfc
Update BundleDataClient.ts
nicholaspai 5c32bd8
Update BundleDataClient.ts
nicholaspai 7eb7ede
Update package.json
nicholaspai d614976
Update package.json
nicholaspai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,9 @@ import { | |
| ExpiredDepositsToRefundV3, | ||
| Clients, | ||
| CombinedRefunds, | ||
| FillWithBlock, | ||
| Deposit, | ||
| DepositWithBlock, | ||
| } from "../../interfaces"; | ||
| import { AcrossConfigStoreClient, SpokePoolClient } from ".."; | ||
| import { | ||
|
|
@@ -32,6 +35,7 @@ import { | |
| mapAsync, | ||
| bnUint32Max, | ||
| isZeroValueDeposit, | ||
| findFillEvent, | ||
| } from "../../utils"; | ||
| import winston from "winston"; | ||
| import { | ||
|
|
@@ -665,6 +669,37 @@ export class BundleDataClient { | |
| return isChainDisabled(blockRangeForChain); | ||
| }; | ||
|
|
||
| const _canCreateSlowFillLeaf = (deposit: DepositWithBlock): boolean => { | ||
| return ( | ||
| // Cannot slow fill when input and output tokens are not equivalent. | ||
| this.clients.hubPoolClient.areTokensEquivalent( | ||
| deposit.inputToken, | ||
| deposit.originChainId, | ||
| deposit.outputToken, | ||
| deposit.destinationChainId, | ||
| deposit.quoteBlockNumber | ||
| ) && | ||
| // Cannot slow fill from or to a lite chain. | ||
| !deposit.fromLiteChain && | ||
| !deposit.toLiteChain && | ||
| // Deposit must not expire during this bundle. | ||
| deposit.fillDeadline >= bundleBlockTimestamps[deposit.destinationChainId][1] | ||
| ); | ||
| }; | ||
|
|
||
| const _getFillStatusForDeposit = (deposit: Deposit, queryBlock: number): Promise<FillStatus> => { | ||
| return spokePoolClients[deposit.destinationChainId].relayFillStatus( | ||
| deposit, | ||
| // We can assume that in production | ||
| // the block to query is not one that the spoke pool client | ||
| // hasn't queried. This is because this function will usually be called | ||
| // in production with block ranges that were validated by | ||
| // DataworkerUtils.blockRangesAreInvalidForSpokeClients. | ||
| Math.min(queryBlock, spokePoolClients[deposit.destinationChainId].latestBlockSearched), | ||
| deposit.destinationChainId | ||
| ); | ||
| }; | ||
|
|
||
| // Infer chain ID's to load from number of block ranges passed in. | ||
| const allChainIds = blockRangesForChains | ||
| .map((_blockRange, index) => chainIds[index]) | ||
|
|
@@ -779,7 +814,10 @@ export class BundleDataClient { | |
| continue; | ||
| } | ||
| originClient.getDepositsForDestinationChain(destinationChainId).forEach((deposit) => { | ||
| if (isZeroValueDeposit(deposit)) { | ||
| // Only evaluate deposits that are in this bundle or in previous bundles. This means we cannot issue fill | ||
| // refunds or slow fills here for deposits that are in future bundles (i.e. "pre-fills"). Instead, we'll | ||
| // evaluate these pre-fills once the deposit is inside the "current" bundle block range. | ||
| if (isZeroValueDeposit(deposit) || deposit.blockNumber > originChainBlockRange[1]) { | ||
| return; | ||
| } | ||
| depositCounter++; | ||
|
|
@@ -809,7 +847,7 @@ export class BundleDataClient { | |
| // If deposit is in bundle and it has expired, additionally save it as an expired deposit. | ||
| // If deposit is not in the bundle block range, then save it as an older deposit that | ||
| // may have expired. | ||
| if (deposit.blockNumber >= originChainBlockRange[0] && deposit.blockNumber <= originChainBlockRange[1]) { | ||
| if (deposit.blockNumber >= originChainBlockRange[0]) { | ||
| // Deposit is a V3 deposit in this origin chain's bundle block range and is not a duplicate. | ||
| updateBundleDepositsV3(bundleDepositsV3, deposit); | ||
| // We don't check that fillDeadline >= bundleBlockTimestamps[destinationChainId][0] because | ||
|
|
@@ -968,36 +1006,14 @@ export class BundleDataClient { | |
| ); | ||
| // The ! is safe here because we've already checked that the deposit exists in the relay hash dictionary. | ||
| const matchedDeposit = v3RelayHashes[relayDataHash].deposit!; | ||
|
|
||
| // Input and Output tokens must be equivalent on the deposit for this to be slow filled. | ||
| if ( | ||
| !this.clients.hubPoolClient.areTokensEquivalent( | ||
| matchedDeposit.inputToken, | ||
| matchedDeposit.originChainId, | ||
| matchedDeposit.outputToken, | ||
| matchedDeposit.destinationChainId, | ||
| matchedDeposit.quoteBlockNumber | ||
| ) | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| // slow fill requests for deposits from or to lite chains are considered invalid | ||
| if ( | ||
| v3RelayHashes[relayDataHash].deposit?.fromLiteChain || | ||
| v3RelayHashes[relayDataHash].deposit?.toLiteChain | ||
| ) { | ||
| if (!_canCreateSlowFillLeaf(matchedDeposit)) { | ||
| return; | ||
| } | ||
|
|
||
| // If there is no fill matching the relay hash, then this might be a valid slow fill request | ||
| // that we should produce a slow fill leaf for. Check if the slow fill request is in the | ||
| // destination chain block range and that the underlying deposit has not expired yet. | ||
| if ( | ||
| slowFillRequest.blockNumber >= destinationChainBlockRange[0] && | ||
| // Deposit must not have expired in this bundle. | ||
| slowFillRequest.fillDeadline >= bundleBlockTimestamps[destinationChainId][1] | ||
| ) { | ||
| // destination chain block range. | ||
| if (slowFillRequest.blockNumber >= destinationChainBlockRange[0]) { | ||
| // At this point, the v3RelayHashes entry already existed meaning that there is a matching deposit, | ||
| // so this slow fill request relay data is correct. | ||
| validatedBundleSlowFills.push(matchedDeposit); | ||
|
|
@@ -1043,37 +1059,106 @@ export class BundleDataClient { | |
| this.getRelayHashFromEvent(matchedDeposit) === relayDataHash, | ||
| "Deposit relay hashes should match." | ||
| ); | ||
| v3RelayHashes[relayDataHash].deposit = matchedDeposit; | ||
|
|
||
| // slow fill requests for deposits from or to lite chains are considered invalid | ||
| if (matchedDeposit.fromLiteChain || matchedDeposit.toLiteChain) { | ||
| if (!_canCreateSlowFillLeaf(matchedDeposit)) { | ||
| return; | ||
| } | ||
|
|
||
| v3RelayHashes[relayDataHash].deposit = matchedDeposit; | ||
|
|
||
| // Note: we don't need to query for a historical fill at this point because a fill | ||
| // cannot precede a slow fill request and if the fill came after the slow fill request, | ||
| // we would have seen it already because we would have processed it in the loop above. | ||
| if ( | ||
| // Input and Output tokens must be equivalent on the deposit for this to be slow filled. | ||
| !this.clients.hubPoolClient.areTokensEquivalent( | ||
| matchedDeposit.inputToken, | ||
| matchedDeposit.originChainId, | ||
| matchedDeposit.outputToken, | ||
| matchedDeposit.destinationChainId, | ||
| matchedDeposit.quoteBlockNumber | ||
| ) || | ||
| // Deposit must not have expired in this bundle. | ||
| slowFillRequest.fillDeadline < bundleBlockTimestamps[destinationChainId][1] | ||
| ) { | ||
| // TODO: Invalid slow fill request. Maybe worth logging. | ||
| return; | ||
| } | ||
| validatedBundleSlowFills.push(matchedDeposit); | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| // The above loops for adding deposits, fills, and then slow fill requests to the relay hash dictionary | ||
| // ignore any events that are after the bundle block range. Because of that, we should consider that there | ||
| // are deposits in this bundle that correspond to fills that were sent in a prior bundle that have not | ||
| // yet been refunded. These fills are also known as "pre-fills" from here on. | ||
| const originBlockRange = getBlockRangeForChain(blockRangesForChains, originChainId, chainIds); | ||
|
|
||
| await mapAsync( | ||
| Object.values(v3RelayHashes).filter( | ||
| ({ deposit }) => | ||
| deposit && | ||
| deposit.originChainId === originChainId && | ||
| deposit.destinationChainId === destinationChainId && | ||
| deposit.blockNumber >= originBlockRange[0] && | ||
nicholaspai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| deposit.blockNumber <= originBlockRange[1] && | ||
| !isZeroValueDeposit(deposit) | ||
| ), | ||
| async ({ deposit, fill, slowFillRequest }) => { | ||
| if (!deposit) throw new Error("Deposit should exist in relay hash dictionary."); | ||
| // We don't check the deposit's fillDeadline here because we are ok if the deposit expires in this bundle | ||
| // and we issue an expiry refund for it. This expired deposit could also have been pre-filled and we just | ||
| // want to make sure in this code block that all valid pre-fills get refunded once the deposit appears. | ||
| // If a pre-fill gets refunded and its deposit expired and gets refunded as well, then there is no net loss | ||
| // to the protocol. | ||
|
|
||
| // If fill exists in memory, then the only case in which we need to create a refund is if the | ||
| // the fill occurred in a previous bundle. | ||
| if (fill) { | ||
| if (!isSlowFill(fill) && fill.blockNumber < destinationChainBlockRange[0]) { | ||
| // If fill is in the current bundle then we can assume there is already a refund for it, so only | ||
| // include this pre fill if the fill is in an older bundle. If fill is after this current bundle, then | ||
| // we won't consider it, following the previous treatment of fills after the bundle block range. | ||
| validatedBundleV3Fills.push({ | ||
nicholaspai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ...fill, | ||
| quoteTimestamp: deposit.quoteTimestamp, | ||
| }); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| // If fill does not exist in memory but there is a slow fill request in memory, then we need to issue a | ||
| // slow fill leaf for the deposit. We can assume there was no fill preceding the slow fill request because | ||
| // slow fill requests cannot follow fills. If there were a fill following this request, we would have | ||
| // entered the above case. Again as with pre-fills, we should only consider slow fill requests that were | ||
| // in previous bundles. | ||
| if (slowFillRequest) { | ||
| if (_canCreateSlowFillLeaf(deposit) && slowFillRequest.blockNumber < destinationChainBlockRange[0]) { | ||
| validatedBundleSlowFills.push(deposit); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| // So at this point in the code, there is no fill or slow fill request in memory for this deposit. | ||
| // We need to check its fill status on-chain to figure out whether to issue a refund or a slow fill leaf. | ||
| // We can assume at this point that all fills or slow fill requests, if found, were in previous bundles | ||
| // because the spoke pool client lookback would have returned this entire bundle of events and stored | ||
| // them into the relay hash dictionary. | ||
| const fillStatus = await _getFillStatusForDeposit(deposit, destinationChainBlockRange[1]); | ||
|
|
||
| // If deposit was filled, then we need to issue a refund for it. | ||
| if (fillStatus === FillStatus.Filled) { | ||
| // We need to find the fill event to issue a refund to the right relayer and repayment chain, | ||
| // or msg.sender if relayer address is invalid for the repayment chain. | ||
| const prefill = (await findFillEvent( | ||
| destinationClient.spokePool, | ||
| deposit, | ||
| destinationClient.deploymentBlock, | ||
| destinationClient.latestBlockSearched | ||
| )) as unknown as FillWithBlock; | ||
| if (!isSlowFill(prefill)) { | ||
nicholaspai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| validatedBundleV3Fills.push({ | ||
| ...prefill, | ||
|
||
| quoteTimestamp: deposit.quoteTimestamp, | ||
| }); | ||
| } | ||
| } | ||
| // If slow fill requested, then issue a slow fill leaf for the deposit. | ||
| else if (fillStatus === FillStatus.RequestedSlowFill) { | ||
| // Input and Output tokens must be equivalent on the deposit for this to be slow filled. | ||
| // Slow fill requests for deposits from or to lite chains are considered invalid | ||
| if (_canCreateSlowFillLeaf(deposit)) { | ||
| validatedBundleSlowFills.push(deposit); | ||
| } | ||
| } | ||
| } | ||
| ); | ||
|
|
||
| // For all fills that came after a slow fill request, we can now check if the slow fill request | ||
| // was a valid one and whether it was created in a previous bundle. If so, then it created a slow fill | ||
| // leaf that is now unexecutable. | ||
|
|
@@ -1119,23 +1204,11 @@ export class BundleDataClient { | |
| }); | ||
| start = performance.now(); | ||
|
|
||
| // Go through expired deposits in this bundle and now prune those that we have seen a fill for to construct | ||
| // the list of expired deposits we need to refund in this bundle. | ||
| expiredBundleDepositHashes.forEach((relayDataHash) => { | ||
nicholaspai marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const { deposit, fill } = v3RelayHashes[relayDataHash]; | ||
| assert(isDefined(deposit), "Deposit should exist in relay hash dictionary."); | ||
| if ( | ||
| !fill && | ||
| isDefined(deposit) // Needed for TSC - we check this above. | ||
| ) { | ||
| updateExpiredDepositsV3(expiredDepositsToRefundV3, deposit); | ||
| } | ||
| }); | ||
|
|
||
| // For all deposits older than this bundle, we need to check if they expired in this bundle and if they did, | ||
| // whether there was a slow fill created for it in a previous bundle that is now unexecutable and replaced | ||
| // by a new expired deposit refund. | ||
| await forEachAsync(Array.from(olderDepositHashes), async (relayDataHash) => { | ||
| // Add any newly expired deposits to the list of expired deposits to refund. | ||
| // For these refunds, we need to check whether there was a slow fill created for it in a previous bundle | ||
| // that is now unexecutable and replaced by a new expired deposit refund. | ||
| const possibleExpiredDeposits = Array.from(olderDepositHashes).concat(Array.from(expiredBundleDepositHashes)); | ||
nicholaspai marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| await forEachAsync(possibleExpiredDeposits, async (relayDataHash) => { | ||
| const { deposit, slowFillRequest, fill } = v3RelayHashes[relayDataHash]; | ||
| assert(isDefined(deposit), "Deposit should exist in relay hash dictionary."); | ||
| const { destinationChainId } = deposit!; | ||
|
|
@@ -1154,16 +1227,7 @@ export class BundleDataClient { | |
| ) { | ||
| // If we haven't seen a fill matching this deposit, then we need to rule out that it was filled a long time ago | ||
| // by checkings its on-chain fill status. | ||
| const fillStatus = await spokePoolClients[destinationChainId].relayFillStatus( | ||
| deposit, | ||
| // We can assume that in production | ||
| // the block ranges passed into this function would never contain blocks where the spoke pool client | ||
| // hasn't queried. This is because this function will usually be called | ||
| // in production with block ranges that were validated by | ||
| // DataworkerUtils.blockRangesAreInvalidForSpokeClients | ||
| Math.min(destinationBlockRange[1], spokePoolClients[destinationChainId].latestBlockSearched), | ||
| destinationChainId | ||
| ); | ||
| const fillStatus = await _getFillStatusForDeposit(deposit, destinationBlockRange[1]); | ||
|
|
||
| // If there is no matching fill and the deposit expired in this bundle and the fill status on-chain is not | ||
| // Filled, then we can to refund it as an expired deposit. | ||
bmzig marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.