@hyperlane-xyz/rebalancer 27.2.11 → 27.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/InventoryRebalancer.d.ts +11 -19
- package/dist/core/InventoryRebalancer.d.ts.map +1 -1
- package/dist/core/InventoryRebalancer.js +336 -268
- package/dist/core/InventoryRebalancer.js.map +1 -1
- package/dist/core/InventoryRebalancer.test.js +397 -23
- package/dist/core/InventoryRebalancer.test.js.map +1 -1
- package/dist/core/Rebalancer.d.ts.map +1 -1
- package/dist/core/Rebalancer.js +12 -6
- package/dist/core/Rebalancer.js.map +1 -1
- package/dist/core/Rebalancer.test.js +51 -0
- package/dist/core/Rebalancer.test.js.map +1 -1
- package/dist/core/RebalancerOrchestrator.test.js +0 -1
- package/dist/core/RebalancerOrchestrator.test.js.map +1 -1
- package/dist/core/RebalancerService.d.ts +2 -3
- package/dist/core/RebalancerService.d.ts.map +1 -1
- package/dist/core/RebalancerService.js +3 -2
- package/dist/core/RebalancerService.js.map +1 -1
- package/dist/core/RebalancerService.test.js +24 -0
- package/dist/core/RebalancerService.test.js.map +1 -1
- package/dist/e2e/harness/TestHelpers.js +1 -2
- package/dist/e2e/harness/TestHelpers.js.map +1 -1
- package/dist/factories/RebalancerContextFactory.d.ts +4 -5
- package/dist/factories/RebalancerContextFactory.d.ts.map +1 -1
- package/dist/factories/RebalancerContextFactory.js +12 -7
- package/dist/factories/RebalancerContextFactory.js.map +1 -1
- package/dist/factories/RebalancerContextFactory.test.js +99 -2
- package/dist/factories/RebalancerContextFactory.test.js.map +1 -1
- package/dist/interfaces/IRebalancer.d.ts +4 -2
- package/dist/interfaces/IRebalancer.d.ts.map +1 -1
- package/dist/metrics/scripts/metrics.d.ts +1 -1
- package/dist/monitor/Monitor.d.ts.map +1 -1
- package/dist/monitor/Monitor.js +14 -6
- package/dist/monitor/Monitor.js.map +1 -1
- package/dist/strategy/BaseStrategy.d.ts.map +1 -1
- package/dist/strategy/BaseStrategy.js +13 -11
- package/dist/strategy/BaseStrategy.js.map +1 -1
- package/dist/strategy/CollateralDeficitStrategy.d.ts.map +1 -1
- package/dist/strategy/CollateralDeficitStrategy.js +2 -2
- package/dist/strategy/CollateralDeficitStrategy.js.map +1 -1
- package/dist/strategy/MinAmountStrategy.d.ts +1 -0
- package/dist/strategy/MinAmountStrategy.d.ts.map +1 -1
- package/dist/strategy/MinAmountStrategy.js +12 -8
- package/dist/strategy/MinAmountStrategy.js.map +1 -1
- package/dist/strategy/MinAmountStrategy.test.js +189 -2
- package/dist/strategy/MinAmountStrategy.test.js.map +1 -1
- package/dist/test/helpers.d.ts +11 -3
- package/dist/test/helpers.d.ts.map +1 -1
- package/dist/test/helpers.js +9 -11
- package/dist/test/helpers.js.map +1 -1
- package/dist/test/lifiMocks.d.ts.map +1 -1
- package/dist/test/lifiMocks.js +5 -2
- package/dist/test/lifiMocks.js.map +1 -1
- package/dist/tracking/ActionTracker.d.ts.map +1 -1
- package/dist/tracking/ActionTracker.js +2 -1
- package/dist/tracking/ActionTracker.js.map +1 -1
- package/dist/tracking/ActionTracker.test.js +39 -0
- package/dist/tracking/ActionTracker.test.js.map +1 -1
- package/dist/utils/balanceUtils.d.ts +7 -1
- package/dist/utils/balanceUtils.d.ts.map +1 -1
- package/dist/utils/balanceUtils.js +39 -1
- package/dist/utils/balanceUtils.js.map +1 -1
- package/dist/utils/balanceUtils.test.js +55 -1
- package/dist/utils/balanceUtils.test.js.map +1 -1
- package/dist/utils/blockTag.d.ts +3 -3
- package/dist/utils/blockTag.d.ts.map +1 -1
- package/dist/utils/blockTag.js +1 -1
- package/dist/utils/blockTag.js.map +1 -1
- package/package.json +7 -7
- package/src/core/InventoryRebalancer.test.ts +503 -38
- package/src/core/InventoryRebalancer.ts +483 -350
- package/src/core/Rebalancer.test.ts +84 -0
- package/src/core/Rebalancer.ts +22 -6
- package/src/core/RebalancerOrchestrator.test.ts +0 -1
- package/src/core/RebalancerService.test.ts +35 -0
- package/src/core/RebalancerService.ts +9 -5
- package/src/e2e/harness/TestHelpers.ts +3 -3
- package/src/factories/RebalancerContextFactory.test.ts +143 -6
- package/src/factories/RebalancerContextFactory.ts +29 -17
- package/src/interfaces/IRebalancer.ts +4 -1
- package/src/monitor/Monitor.ts +19 -6
- package/src/strategy/BaseStrategy.ts +18 -15
- package/src/strategy/CollateralDeficitStrategy.ts +4 -3
- package/src/strategy/MinAmountStrategy.test.ts +238 -2
- package/src/strategy/MinAmountStrategy.ts +29 -17
- package/src/test/helpers.ts +13 -12
- package/src/test/lifiMocks.ts +5 -2
- package/src/tracking/ActionTracker.test.ts +47 -0
- package/src/tracking/ActionTracker.ts +2 -1
- package/src/utils/balanceUtils.test.ts +87 -1
- package/src/utils/balanceUtils.ts +73 -2
- package/src/utils/blockTag.ts +9 -4
|
@@ -123,6 +123,7 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
123
123
|
}),
|
|
124
124
|
},
|
|
125
125
|
findToken: Sinon.stub().returns(null),
|
|
126
|
+
getMaxTransferAmount: Sinon.stub().callsFake(async ({ balance }) => balance),
|
|
126
127
|
getTransferRemoteTxs: Sinon.stub().resolves([
|
|
127
128
|
{
|
|
128
129
|
category: 'transfer',
|
|
@@ -230,6 +231,18 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
230
231
|
actionTracker.createRebalanceIntent.resolves(intent);
|
|
231
232
|
return intent;
|
|
232
233
|
}
|
|
234
|
+
function mockSuccessfulBridge(fromAmount, toAmount) {
|
|
235
|
+
bridge.quote.resolves(createMockBridgeQuote({
|
|
236
|
+
fromAmount,
|
|
237
|
+
toAmount,
|
|
238
|
+
toAmountMin: toAmount,
|
|
239
|
+
}));
|
|
240
|
+
bridge.execute.resolves({
|
|
241
|
+
txHash: '0xBridgeTxHash',
|
|
242
|
+
fromChain: 42161,
|
|
243
|
+
toChain: 1399811149,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
233
246
|
describe('Basic Inventory Rebalance (Sufficient Inventory)', () => {
|
|
234
247
|
// NOTE: Strategy route is arbitrum (surplus) → solana (deficit)
|
|
235
248
|
// But execution calls transferRemote FROM solana TO arbitrum (swapped direction)
|
|
@@ -323,10 +336,30 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
323
336
|
const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
|
|
324
337
|
expect(actionParams.txHash).to.equal('0xSolanaTxHash');
|
|
325
338
|
});
|
|
339
|
+
it('denormalizes inventory execution amounts but records canonical deposit amount', async () => {
|
|
340
|
+
const route = createTestRoute({ amount: 1000000n });
|
|
341
|
+
createTestIntent({ amount: 1000000n });
|
|
342
|
+
warpCore.tokens.find((t) => t.chainName === SOLANA_CHAIN).scale = {
|
|
343
|
+
numerator: 1n,
|
|
344
|
+
denominator: 1000000000000n,
|
|
345
|
+
};
|
|
346
|
+
inventoryRebalancer.setInventoryBalances({
|
|
347
|
+
[SOLANA_CHAIN]: 1000000000000000000n,
|
|
348
|
+
[ARBITRUM_CHAIN]: 0n,
|
|
349
|
+
});
|
|
350
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
351
|
+
expect(results).to.have.lengthOf(1);
|
|
352
|
+
expect(results[0].success).to.be.true;
|
|
353
|
+
const txParams = warpCore.getTransferRemoteTxs.firstCall.args[0];
|
|
354
|
+
expect(txParams.originTokenAmount.amount).to.equal(1000000000000000000n);
|
|
355
|
+
const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
|
|
356
|
+
expect(actionParams.amount).to.equal(1000000n);
|
|
357
|
+
});
|
|
326
358
|
});
|
|
327
359
|
describe('Partial Fulfillment (Insufficient Inventory)', () => {
|
|
328
|
-
// Partial transfers happen when maxTransferable >= minViableTransfer
|
|
329
|
-
// For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so
|
|
360
|
+
// Partial transfers happen when maxTransferable >= minViableTransfer.
|
|
361
|
+
// For non-native tokens (EvmHypCollateral), minViableTransfer = 0, so any
|
|
362
|
+
// positive fee-aware maxTransferable remains viable.
|
|
330
363
|
const PARTIAL_AMOUNT = BigInt(5e15); // 0.005 ETH - above threshold
|
|
331
364
|
const FULL_AMOUNT = BigInt(1e16); // 0.01 ETH
|
|
332
365
|
it('executes partial transferRemote when maxTransferable >= minViableTransfer', async () => {
|
|
@@ -351,6 +384,49 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
351
384
|
const actionParams = actionTracker.createRebalanceAction.firstCall.args[0];
|
|
352
385
|
expect(actionParams.amount).to.equal(PARTIAL_AMOUNT);
|
|
353
386
|
});
|
|
387
|
+
it('uses fee-aware maxTransferable for non-native token fees', async () => {
|
|
388
|
+
const requestedAmount = 19998000000n;
|
|
389
|
+
const availableInventory = 102466n;
|
|
390
|
+
const safeTransferAmount = 102400n;
|
|
391
|
+
warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
|
|
392
|
+
const route = createTestRoute({ amount: requestedAmount });
|
|
393
|
+
createTestIntent({ amount: requestedAmount });
|
|
394
|
+
inventoryRebalancer.setInventoryBalances({
|
|
395
|
+
[SOLANA_CHAIN]: availableInventory,
|
|
396
|
+
[ARBITRUM_CHAIN]: 0n,
|
|
397
|
+
});
|
|
398
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
399
|
+
expect(results).to.have.lengthOf(1);
|
|
400
|
+
expect(results[0].success).to.be.true;
|
|
401
|
+
expect(warpCore.getMaxTransferAmount.calledOnce).to.be.true;
|
|
402
|
+
const maxTransferArgs = warpCore.getMaxTransferAmount.firstCall.args[0];
|
|
403
|
+
expect(maxTransferArgs.balance.amount).to.equal(availableInventory);
|
|
404
|
+
expect(maxTransferArgs.destination).to.equal(ARBITRUM_CHAIN);
|
|
405
|
+
const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
|
|
406
|
+
expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
|
|
407
|
+
expect(txParams).to.not.have.property('interchainFee');
|
|
408
|
+
expect(txParams).to.not.have.property('tokenFeeQuote');
|
|
409
|
+
const actionParams = actionTracker.createRebalanceAction.lastCall.args[0];
|
|
410
|
+
expect(actionParams.amount).to.equal(safeTransferAmount);
|
|
411
|
+
});
|
|
412
|
+
it('downgrades full non-native transfers to partial when fee headroom is missing', async () => {
|
|
413
|
+
const requestedAmount = 102466n;
|
|
414
|
+
const safeTransferAmount = 102400n;
|
|
415
|
+
warpCore.getMaxTransferAmount.resolves({ amount: safeTransferAmount });
|
|
416
|
+
const route = createTestRoute({ amount: requestedAmount });
|
|
417
|
+
createTestIntent({ amount: requestedAmount });
|
|
418
|
+
inventoryRebalancer.setInventoryBalances({
|
|
419
|
+
[SOLANA_CHAIN]: requestedAmount,
|
|
420
|
+
[ARBITRUM_CHAIN]: 0n,
|
|
421
|
+
});
|
|
422
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
423
|
+
expect(results).to.have.lengthOf(1);
|
|
424
|
+
expect(results[0].success).to.be.true;
|
|
425
|
+
expect(bridge.execute.called).to.be.false;
|
|
426
|
+
const txParams = warpCore.getTransferRemoteTxs.lastCall.args[0];
|
|
427
|
+
expect(txParams.originTokenAmount.amount).to.equal(safeTransferAmount);
|
|
428
|
+
expect(txParams.originTokenAmount.amount).to.not.equal(requestedAmount);
|
|
429
|
+
});
|
|
354
430
|
it('intent remains in_progress after partial fulfillment', async () => {
|
|
355
431
|
const route = createTestRoute({ amount: FULL_AMOUNT });
|
|
356
432
|
createTestIntent({ amount: FULL_AMOUNT });
|
|
@@ -364,6 +440,42 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
364
440
|
expect(results[0].success).to.be.true;
|
|
365
441
|
});
|
|
366
442
|
});
|
|
443
|
+
describe('Fee-Aware Probe Fallback', () => {
|
|
444
|
+
it('bridges when non-native destination inventory is zero', async () => {
|
|
445
|
+
const requestedAmount = 10000000000n;
|
|
446
|
+
const bufferedBridgeAmount = (requestedAmount * 105n) / 100n;
|
|
447
|
+
const route = createTestRoute({ amount: requestedAmount });
|
|
448
|
+
createTestIntent({ amount: requestedAmount });
|
|
449
|
+
inventoryRebalancer.setInventoryBalances({
|
|
450
|
+
[SOLANA_CHAIN]: 0n,
|
|
451
|
+
[ARBITRUM_CHAIN]: 20000000000n,
|
|
452
|
+
});
|
|
453
|
+
mockSuccessfulBridge(bufferedBridgeAmount, bufferedBridgeAmount);
|
|
454
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
455
|
+
expect(results).to.have.lengthOf(1);
|
|
456
|
+
expect(results[0].success).to.be.true;
|
|
457
|
+
expect(warpCore.getMaxTransferAmount.called).to.be.false;
|
|
458
|
+
expect(warpCore.getTransferRemoteTxs.called).to.be.false;
|
|
459
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
460
|
+
const actionParams = actionTracker.createRebalanceAction.firstCall.args[0];
|
|
461
|
+
expect(actionParams.type).to.equal('inventory_movement');
|
|
462
|
+
});
|
|
463
|
+
it('returns failure for unrelated fee-aware probe errors', async () => {
|
|
464
|
+
const route = createTestRoute();
|
|
465
|
+
createTestIntent();
|
|
466
|
+
inventoryRebalancer.setInventoryBalances({
|
|
467
|
+
[SOLANA_CHAIN]: 1n,
|
|
468
|
+
[ARBITRUM_CHAIN]: 20000000000n,
|
|
469
|
+
});
|
|
470
|
+
warpCore.getMaxTransferAmount.rejects(new Error('RPC down'));
|
|
471
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
472
|
+
expect(results).to.have.lengthOf(1);
|
|
473
|
+
expect(results[0].success).to.be.false;
|
|
474
|
+
expect(results[0].error).to.include('RPC down');
|
|
475
|
+
expect(bridge.execute.called).to.be.false;
|
|
476
|
+
expect(actionTracker.createRebalanceAction.called).to.be.false;
|
|
477
|
+
});
|
|
478
|
+
});
|
|
367
479
|
describe('No Inventory Available', () => {
|
|
368
480
|
it('returns failure when no inventory on destination chain and no other source available', async () => {
|
|
369
481
|
const route = createTestRoute();
|
|
@@ -507,6 +619,42 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
507
619
|
// Verify: No new intent was created (existing was continued)
|
|
508
620
|
expect(actionTracker.createRebalanceIntent.called).to.be.false;
|
|
509
621
|
});
|
|
622
|
+
it('continues existing intent by bridging after recoverable fee-aware probe failure', async () => {
|
|
623
|
+
const existingIntent = createTestIntent({
|
|
624
|
+
id: 'existing-intent',
|
|
625
|
+
status: 'in_progress',
|
|
626
|
+
amount: 10000000000n,
|
|
627
|
+
externalBridge: ExternalBridgeType.LiFi,
|
|
628
|
+
});
|
|
629
|
+
const recoverableProbeError = new Error('Fee probe failed');
|
|
630
|
+
recoverableProbeError.cause = {
|
|
631
|
+
code: 'UNPREDICTABLE_GAS_LIMIT',
|
|
632
|
+
message: 'execution reverted: ERC20: transfer amount exceeds balance',
|
|
633
|
+
};
|
|
634
|
+
actionTracker.getPartiallyFulfilledInventoryIntents.resolves([
|
|
635
|
+
{
|
|
636
|
+
intent: existingIntent,
|
|
637
|
+
completedAmount: 0n,
|
|
638
|
+
remaining: 10000000000n,
|
|
639
|
+
hasInflightDeposit: false,
|
|
640
|
+
},
|
|
641
|
+
]);
|
|
642
|
+
inventoryRebalancer.setInventoryBalances({
|
|
643
|
+
[SOLANA_CHAIN]: 1n,
|
|
644
|
+
[ARBITRUM_CHAIN]: 20000000000n,
|
|
645
|
+
});
|
|
646
|
+
warpCore.getMaxTransferAmount.rejects(recoverableProbeError);
|
|
647
|
+
mockSuccessfulBridge(10500000000n, 10500000000n);
|
|
648
|
+
const results = await inventoryRebalancer.rebalance([
|
|
649
|
+
createTestRoute({ amount: 5000000000n }),
|
|
650
|
+
]);
|
|
651
|
+
expect(results).to.have.lengthOf(1);
|
|
652
|
+
expect(results[0].success).to.be.true;
|
|
653
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
654
|
+
expect(actionTracker.createRebalanceIntent.called).to.be.false;
|
|
655
|
+
const actionParams = actionTracker.createRebalanceAction.firstCall.args[0];
|
|
656
|
+
expect(actionParams.type).to.equal('inventory_movement');
|
|
657
|
+
});
|
|
510
658
|
});
|
|
511
659
|
describe('Error Handling', () => {
|
|
512
660
|
it('handles transaction send failure', async () => {
|
|
@@ -1190,6 +1338,58 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1190
1338
|
// Verify: Bridge was called twice (once for each source)
|
|
1191
1339
|
expect(bridge.execute.callCount).to.equal(2);
|
|
1192
1340
|
});
|
|
1341
|
+
it('does not inflate each split bridge plan to minViableTransfer', async () => {
|
|
1342
|
+
const amount = 7000000000000000n;
|
|
1343
|
+
const perChainInventory = 6000000000000000n;
|
|
1344
|
+
const reservedGas = 1000000000n;
|
|
1345
|
+
for (const token of warpCore.tokens) {
|
|
1346
|
+
if (token.chainName === ARBITRUM_CHAIN ||
|
|
1347
|
+
token.chainName === SOLANA_CHAIN ||
|
|
1348
|
+
token.chainName === BASE_CHAIN) {
|
|
1349
|
+
token.standard = TokenStandard.EvmHypNative;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
const route = createTestRoute({ amount });
|
|
1353
|
+
createTestIntent({ amount });
|
|
1354
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1355
|
+
[SOLANA_CHAIN]: 0n,
|
|
1356
|
+
[ARBITRUM_CHAIN]: perChainInventory,
|
|
1357
|
+
[BASE_CHAIN]: perChainInventory,
|
|
1358
|
+
});
|
|
1359
|
+
bridge.quote.callsFake(async (params) => createMockBridgeQuote({
|
|
1360
|
+
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1361
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1362
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1363
|
+
requestParams: params.toAmount !== undefined
|
|
1364
|
+
? { ...params, toAmount: params.toAmount }
|
|
1365
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1366
|
+
}));
|
|
1367
|
+
bridge.execute.resolves({
|
|
1368
|
+
txHash: '0xBridgeTxHash',
|
|
1369
|
+
fromChain: 42161,
|
|
1370
|
+
toChain: 1399811149,
|
|
1371
|
+
});
|
|
1372
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1373
|
+
expect(results).to.have.lengthOf(1);
|
|
1374
|
+
expect(results[0].success).to.be.true;
|
|
1375
|
+
expect(bridge.execute.callCount).to.equal(2);
|
|
1376
|
+
const reverseQuoteRequests = bridge.quote
|
|
1377
|
+
.getCalls()
|
|
1378
|
+
.map((call) => call.args[0])
|
|
1379
|
+
.filter((params) => params.toAmount !== undefined);
|
|
1380
|
+
expect(reverseQuoteRequests).to.have.lengthOf(2);
|
|
1381
|
+
const requestedOutputs = reverseQuoteRequests
|
|
1382
|
+
.map((params) => params.toAmount)
|
|
1383
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
1384
|
+
const maxPerSourceOutput = perChainInventory - reservedGas;
|
|
1385
|
+
const totalAvailableCapacity = maxPerSourceOutput * 2n;
|
|
1386
|
+
expect(requestedOutputs.every((output) => output <= maxPerSourceOutput))
|
|
1387
|
+
.to.be.true;
|
|
1388
|
+
expect(requestedOutputs[0] < maxPerSourceOutput).to.be.true;
|
|
1389
|
+
expect(requestedOutputs[1]).to.equal(maxPerSourceOutput);
|
|
1390
|
+
expect(requestedOutputs[0] + requestedOutputs[1] < totalAvailableCapacity)
|
|
1391
|
+
.to.be.true;
|
|
1392
|
+
});
|
|
1193
1393
|
it('applies 5% buffer to total bridge amount', async () => {
|
|
1194
1394
|
// Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
|
|
1195
1395
|
const amount = BigInt(1e18); // 1 ETH
|
|
@@ -1201,14 +1401,18 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1201
1401
|
[SOLANA_CHAIN]: 0n,
|
|
1202
1402
|
[ARBITRUM_CHAIN]: availableInventory,
|
|
1203
1403
|
});
|
|
1204
|
-
|
|
1205
|
-
// (calculateMaxViableBridgeAmount doesn't quote for ERC20 tokens)
|
|
1206
|
-
let quotedFromAmount;
|
|
1404
|
+
let quotedTargetOutput;
|
|
1207
1405
|
bridge.quote.callsFake(async (params) => {
|
|
1208
|
-
|
|
1406
|
+
if (params.toAmount !== undefined) {
|
|
1407
|
+
quotedTargetOutput = params.toAmount;
|
|
1408
|
+
}
|
|
1209
1409
|
return createMockBridgeQuote({
|
|
1210
1410
|
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1211
|
-
toAmount: params.
|
|
1411
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1412
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1413
|
+
requestParams: params.toAmount !== undefined
|
|
1414
|
+
? { ...params, toAmount: params.toAmount }
|
|
1415
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1212
1416
|
});
|
|
1213
1417
|
});
|
|
1214
1418
|
bridge.execute.resolves({
|
|
@@ -1218,10 +1422,131 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1218
1422
|
});
|
|
1219
1423
|
await inventoryRebalancer.rebalance([route]);
|
|
1220
1424
|
// Verify: 5% buffer applied (1 ETH * 1.05 = 1.05 ETH)
|
|
1221
|
-
// The bridge plan uses pre-validated amounts (for ERC20, full inventory available)
|
|
1222
|
-
// But the target is (amount * 105%), so if source has >= target, we bridge exactly target
|
|
1223
1425
|
const expectedWithBuffer = (amount * 105n) / 100n;
|
|
1224
|
-
expect(
|
|
1426
|
+
expect(quotedTargetOutput).to.equal(expectedWithBuffer);
|
|
1427
|
+
});
|
|
1428
|
+
it('bridges only the mixed-decimal shortfall after dust partial skip', async () => {
|
|
1429
|
+
const canonicalAmount = 1n;
|
|
1430
|
+
const destinationDust = 1000000000000n - 1n;
|
|
1431
|
+
const sourceInventory = 1000000000000n;
|
|
1432
|
+
warpCore.tokens.find((t) => t.chainName === SOLANA_CHAIN).scale = {
|
|
1433
|
+
numerator: 1n,
|
|
1434
|
+
denominator: 1000000000000n,
|
|
1435
|
+
};
|
|
1436
|
+
const route = createTestRoute({ amount: canonicalAmount });
|
|
1437
|
+
createTestIntent({ amount: canonicalAmount });
|
|
1438
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1439
|
+
[SOLANA_CHAIN]: destinationDust,
|
|
1440
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1441
|
+
});
|
|
1442
|
+
let reverseQuoteTargetOutput;
|
|
1443
|
+
bridge.quote.callsFake(async (params) => {
|
|
1444
|
+
if (params.toAmount !== undefined) {
|
|
1445
|
+
reverseQuoteTargetOutput = params.toAmount;
|
|
1446
|
+
}
|
|
1447
|
+
return createMockBridgeQuote({
|
|
1448
|
+
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1449
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1450
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1451
|
+
requestParams: params.toAmount !== undefined
|
|
1452
|
+
? { ...params, toAmount: params.toAmount }
|
|
1453
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1454
|
+
});
|
|
1455
|
+
});
|
|
1456
|
+
bridge.execute.resolves({
|
|
1457
|
+
txHash: '0xBridgeTxHash',
|
|
1458
|
+
fromChain: 42161,
|
|
1459
|
+
toChain: 1399811149,
|
|
1460
|
+
});
|
|
1461
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1462
|
+
expect(results).to.have.lengthOf(1);
|
|
1463
|
+
expect(results[0].success).to.be.true;
|
|
1464
|
+
expect(warpCore.getTransferRemoteTxs.called).to.be.false;
|
|
1465
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
1466
|
+
expect(reverseQuoteTargetOutput).to.equal(1n);
|
|
1467
|
+
});
|
|
1468
|
+
it('plans LiFi target output in destination-local units for mixed-decimal routes', async () => {
|
|
1469
|
+
const canonicalAmount = 1000000n;
|
|
1470
|
+
const availableInventory = BigInt(2e18);
|
|
1471
|
+
warpCore.tokens.find((t) => t.chainName === SOLANA_CHAIN).scale = {
|
|
1472
|
+
numerator: 1n,
|
|
1473
|
+
denominator: 1000000000000n,
|
|
1474
|
+
};
|
|
1475
|
+
const route = createTestRoute({ amount: canonicalAmount });
|
|
1476
|
+
createTestIntent({ amount: canonicalAmount });
|
|
1477
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1478
|
+
[SOLANA_CHAIN]: 0n,
|
|
1479
|
+
[ARBITRUM_CHAIN]: availableInventory,
|
|
1480
|
+
});
|
|
1481
|
+
let quotedTargetOutput;
|
|
1482
|
+
bridge.quote.callsFake(async (params) => {
|
|
1483
|
+
if (params.toAmount !== undefined) {
|
|
1484
|
+
quotedTargetOutput = params.toAmount;
|
|
1485
|
+
}
|
|
1486
|
+
return createMockBridgeQuote({
|
|
1487
|
+
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1488
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1489
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1490
|
+
requestParams: params.toAmount !== undefined
|
|
1491
|
+
? { ...params, toAmount: params.toAmount }
|
|
1492
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1493
|
+
});
|
|
1494
|
+
});
|
|
1495
|
+
bridge.execute.resolves({
|
|
1496
|
+
txHash: '0xBridgeTxHash',
|
|
1497
|
+
fromChain: 42161,
|
|
1498
|
+
toChain: 1399811149,
|
|
1499
|
+
});
|
|
1500
|
+
await inventoryRebalancer.rebalance([route]);
|
|
1501
|
+
expect(quotedTargetOutput).to.equal(1050000000000000000n);
|
|
1502
|
+
});
|
|
1503
|
+
it('fails bridge execution when reverse quote exceeds planned source capacity', async () => {
|
|
1504
|
+
const amount = BigInt(1e18);
|
|
1505
|
+
const sourceInventory = BigInt(2e18);
|
|
1506
|
+
const targetWithBuffer = (amount * 105n) / 100n;
|
|
1507
|
+
const route = createTestRoute({ amount });
|
|
1508
|
+
createTestIntent({ amount });
|
|
1509
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1510
|
+
[SOLANA_CHAIN]: 0n,
|
|
1511
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1512
|
+
});
|
|
1513
|
+
bridge.quote
|
|
1514
|
+
.onFirstCall()
|
|
1515
|
+
.resolves(createMockBridgeQuote({
|
|
1516
|
+
fromAmount: sourceInventory,
|
|
1517
|
+
toAmount: sourceInventory,
|
|
1518
|
+
toAmountMin: sourceInventory,
|
|
1519
|
+
requestParams: {
|
|
1520
|
+
fromChain: 42161,
|
|
1521
|
+
toChain: 1399811149,
|
|
1522
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1523
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1524
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1525
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1526
|
+
fromAmount: sourceInventory,
|
|
1527
|
+
},
|
|
1528
|
+
}))
|
|
1529
|
+
.onSecondCall()
|
|
1530
|
+
.resolves(createMockBridgeQuote({
|
|
1531
|
+
fromAmount: sourceInventory + 1n,
|
|
1532
|
+
toAmount: targetWithBuffer,
|
|
1533
|
+
toAmountMin: targetWithBuffer,
|
|
1534
|
+
requestParams: {
|
|
1535
|
+
fromChain: 42161,
|
|
1536
|
+
toChain: 1399811149,
|
|
1537
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1538
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1539
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1540
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1541
|
+
toAmount: targetWithBuffer,
|
|
1542
|
+
},
|
|
1543
|
+
}));
|
|
1544
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1545
|
+
expect(results).to.have.lengthOf(1);
|
|
1546
|
+
expect(results[0].success).to.be.false;
|
|
1547
|
+
expect(results[0].error).to.include('All inventory movements failed');
|
|
1548
|
+
expect(results[0].error).to.include('exceeded planned source capacity');
|
|
1549
|
+
expect(bridge.execute.called).to.be.false;
|
|
1225
1550
|
});
|
|
1226
1551
|
it('continues when some bridges fail', async () => {
|
|
1227
1552
|
const amount = BigInt(1e18);
|
|
@@ -1402,11 +1727,31 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1402
1727
|
[ARBITRUM_CHAIN]: tokenBalance,
|
|
1403
1728
|
});
|
|
1404
1729
|
// Quote with high gas costs (but for ERC20, this shouldn't trigger viability check)
|
|
1405
|
-
bridge.quote
|
|
1406
|
-
|
|
1407
|
-
|
|
1730
|
+
bridge.quote
|
|
1731
|
+
.onFirstCall()
|
|
1732
|
+
.resolves(createMockBridgeQuote({
|
|
1733
|
+
fromAmount: tokenBalance,
|
|
1734
|
+
toAmount: tokenBalance,
|
|
1735
|
+
toAmountMin: tokenBalance,
|
|
1408
1736
|
gasCosts: BigInt(1e18), // Very high gas (would fail viability if this were native)
|
|
1409
1737
|
feeCosts: 0n,
|
|
1738
|
+
}))
|
|
1739
|
+
.onSecondCall()
|
|
1740
|
+
.resolves(createMockBridgeQuote({
|
|
1741
|
+
fromAmount: tokenBalance,
|
|
1742
|
+
toAmount: tokenBalance,
|
|
1743
|
+
toAmountMin: tokenBalance,
|
|
1744
|
+
gasCosts: BigInt(1e18),
|
|
1745
|
+
feeCosts: 0n,
|
|
1746
|
+
requestParams: {
|
|
1747
|
+
fromChain: 42161,
|
|
1748
|
+
toChain: 1399811149,
|
|
1749
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1750
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1751
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1752
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1753
|
+
toAmount: tokenBalance,
|
|
1754
|
+
},
|
|
1410
1755
|
}));
|
|
1411
1756
|
bridge.execute.resolves({
|
|
1412
1757
|
txHash: '0xERC20BridgeTxHash',
|
|
@@ -1418,6 +1763,7 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1418
1763
|
expect(results).to.have.lengthOf(1);
|
|
1419
1764
|
expect(results[0].success).to.be.true;
|
|
1420
1765
|
expect(bridge.execute.calledOnce).to.be.true;
|
|
1766
|
+
expect(bridge.quote.getCall(1)?.args[0].toAmount).to.equal(tokenBalance);
|
|
1421
1767
|
});
|
|
1422
1768
|
it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
|
|
1423
1769
|
// Setup: Native token with enough balance for viable bridge
|
|
@@ -1447,11 +1793,40 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1447
1793
|
// maxViable = 1 ETH - 0.02 ETH = 0.98 ETH
|
|
1448
1794
|
// 10% threshold = 0.1 ETH, estimatedGas (0.02) < threshold → viable
|
|
1449
1795
|
const gasCosts = BigInt(0.001e18); // 0.001 ETH
|
|
1450
|
-
|
|
1796
|
+
const maxViable = rawBalance - gasCosts * 20n;
|
|
1797
|
+
bridge.quote
|
|
1798
|
+
.onFirstCall()
|
|
1799
|
+
.resolves(createMockBridgeQuote({
|
|
1451
1800
|
fromAmount: rawBalance,
|
|
1452
1801
|
toAmount: rawBalance - BigInt(1e15),
|
|
1802
|
+
toAmountMin: rawBalance - BigInt(1e15),
|
|
1803
|
+
gasCosts,
|
|
1804
|
+
feeCosts: 0n,
|
|
1805
|
+
}))
|
|
1806
|
+
.onSecondCall()
|
|
1807
|
+
.resolves(createMockBridgeQuote({
|
|
1808
|
+
fromAmount: maxViable,
|
|
1809
|
+
toAmount: maxViable - BigInt(1e15),
|
|
1810
|
+
toAmountMin: maxViable - BigInt(1e15),
|
|
1453
1811
|
gasCosts,
|
|
1454
1812
|
feeCosts: 0n,
|
|
1813
|
+
}))
|
|
1814
|
+
.onThirdCall()
|
|
1815
|
+
.resolves(createMockBridgeQuote({
|
|
1816
|
+
fromAmount: BigInt(0.525e18),
|
|
1817
|
+
toAmount: BigInt(0.525e18),
|
|
1818
|
+
toAmountMin: BigInt(0.525e18),
|
|
1819
|
+
gasCosts,
|
|
1820
|
+
feeCosts: 0n,
|
|
1821
|
+
requestParams: {
|
|
1822
|
+
fromChain: 42161,
|
|
1823
|
+
toChain: 1399811149,
|
|
1824
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1825
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1826
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1827
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1828
|
+
toAmount: BigInt(0.525e18),
|
|
1829
|
+
},
|
|
1455
1830
|
}));
|
|
1456
1831
|
bridge.execute.resolves({
|
|
1457
1832
|
txHash: '0xSuccessBridgeTxHash',
|
|
@@ -1463,15 +1838,14 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1463
1838
|
expect(results).to.have.lengthOf(1);
|
|
1464
1839
|
expect(results[0].success).to.be.true;
|
|
1465
1840
|
expect(bridge.execute.calledOnce).to.be.true;
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
expect(executionQuoteCall).to.exist;
|
|
1841
|
+
expect(bridge.quote.getCall(1)?.args[0].fromAmount).to.equal(maxViable);
|
|
1842
|
+
const executionTargetOutput = bridge.quote.getCall(2)?.args[0].toAmount;
|
|
1843
|
+
expect(executionTargetOutput).to.be.a('bigint');
|
|
1844
|
+
if (executionTargetOutput === undefined) {
|
|
1845
|
+
throw new Error('Expected reverse quote to set toAmount');
|
|
1846
|
+
}
|
|
1847
|
+
expect(executionTargetOutput > amount).to.be.true;
|
|
1848
|
+
expect(executionTargetOutput <= maxViable).to.be.true;
|
|
1475
1849
|
});
|
|
1476
1850
|
it('handles quote failures gracefully by skipping the source chain', async () => {
|
|
1477
1851
|
// Setup: Native token where quote fails
|