@hyperlane-xyz/rebalancer 27.2.12 → 27.2.14
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/bridges/LiFiBridge.js +1 -1
- package/dist/bridges/LiFiBridge.js.map +1 -1
- package/dist/bridges/LiFiBridge.test.js +37 -0
- package/dist/bridges/LiFiBridge.test.js.map +1 -1
- package/dist/core/InventoryRebalancer.d.ts +13 -19
- package/dist/core/InventoryRebalancer.d.ts.map +1 -1
- package/dist/core/InventoryRebalancer.js +400 -274
- package/dist/core/InventoryRebalancer.js.map +1 -1
- package/dist/core/InventoryRebalancer.test.js +706 -24
- 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/bridges/LiFiBridge.test.ts +43 -0
- package/src/bridges/LiFiBridge.ts +1 -1
- package/src/core/InventoryRebalancer.test.ts +932 -38
- package/src/core/InventoryRebalancer.ts +579 -361
- 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
|
@@ -4,7 +4,7 @@ import { Wallet } from 'ethers';
|
|
|
4
4
|
import { pino } from 'pino';
|
|
5
5
|
import Sinon from 'sinon';
|
|
6
6
|
import { ProviderType, TokenStandard, WarpTxCategory, } from '@hyperlane-xyz/sdk';
|
|
7
|
-
import { ProtocolType } from '@hyperlane-xyz/utils';
|
|
7
|
+
import { assert, ProtocolType } from '@hyperlane-xyz/utils';
|
|
8
8
|
import { ExternalBridgeType } from '../config/types.js';
|
|
9
9
|
import { createMockBridgeQuote } from '../test/lifiMocks.js';
|
|
10
10
|
import { InventoryRebalancer, } from './InventoryRebalancer.js';
|
|
@@ -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,56 @@ 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 maxPerSourceOutput = perChainInventory - reservedGas;
|
|
1377
|
+
const executionQuoteRequests = bridge.quote
|
|
1378
|
+
.getCalls()
|
|
1379
|
+
.slice(-2)
|
|
1380
|
+
.map((call) => call.args[0]);
|
|
1381
|
+
expect(executionQuoteRequests).to.have.lengthOf(2);
|
|
1382
|
+
const forwardQuoteRequests = executionQuoteRequests.filter((params) => params.fromAmount !== undefined);
|
|
1383
|
+
const reverseQuoteRequests = executionQuoteRequests.filter((params) => params.toAmount !== undefined);
|
|
1384
|
+
const forwardAmounts = forwardQuoteRequests.map((params) => params.fromAmount);
|
|
1385
|
+
expect(forwardQuoteRequests.length + reverseQuoteRequests.length).to.equal(2);
|
|
1386
|
+
expect(forwardQuoteRequests.length).to.be.greaterThan(0);
|
|
1387
|
+
expect(forwardAmounts.every((amount) => amount <= maxPerSourceOutput)).to
|
|
1388
|
+
.be.true;
|
|
1389
|
+
expect(reverseQuoteRequests.every((params) => params.toAmount <= maxPerSourceOutput)).to.be.true;
|
|
1390
|
+
});
|
|
1193
1391
|
it('applies 5% buffer to total bridge amount', async () => {
|
|
1194
1392
|
// Need 1 ETH -> should plan to bridge 1.05 ETH total (with 5% buffer)
|
|
1195
1393
|
const amount = BigInt(1e18); // 1 ETH
|
|
@@ -1201,14 +1399,18 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1201
1399
|
[SOLANA_CHAIN]: 0n,
|
|
1202
1400
|
[ARBITRUM_CHAIN]: availableInventory,
|
|
1203
1401
|
});
|
|
1204
|
-
|
|
1205
|
-
// (calculateMaxViableBridgeAmount doesn't quote for ERC20 tokens)
|
|
1206
|
-
let quotedFromAmount;
|
|
1402
|
+
let quotedTargetOutput;
|
|
1207
1403
|
bridge.quote.callsFake(async (params) => {
|
|
1208
|
-
|
|
1404
|
+
if (params.toAmount !== undefined) {
|
|
1405
|
+
quotedTargetOutput = params.toAmount;
|
|
1406
|
+
}
|
|
1209
1407
|
return createMockBridgeQuote({
|
|
1210
1408
|
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1211
|
-
toAmount: params.
|
|
1409
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1410
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1411
|
+
requestParams: params.toAmount !== undefined
|
|
1412
|
+
? { ...params, toAmount: params.toAmount }
|
|
1413
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1212
1414
|
});
|
|
1213
1415
|
});
|
|
1214
1416
|
bridge.execute.resolves({
|
|
@@ -1218,10 +1420,359 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1218
1420
|
});
|
|
1219
1421
|
await inventoryRebalancer.rebalance([route]);
|
|
1220
1422
|
// 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
1423
|
const expectedWithBuffer = (amount * 105n) / 100n;
|
|
1224
|
-
expect(
|
|
1424
|
+
expect(quotedTargetOutput).to.equal(expectedWithBuffer);
|
|
1425
|
+
});
|
|
1426
|
+
it('bridges only the mixed-decimal shortfall after dust partial skip', async () => {
|
|
1427
|
+
const canonicalAmount = 1n;
|
|
1428
|
+
const destinationDust = 1000000000000n - 1n;
|
|
1429
|
+
const sourceInventory = 1000000000000n;
|
|
1430
|
+
warpCore.tokens.find((t) => t.chainName === SOLANA_CHAIN).scale = {
|
|
1431
|
+
numerator: 1n,
|
|
1432
|
+
denominator: 1000000000000n,
|
|
1433
|
+
};
|
|
1434
|
+
const route = createTestRoute({ amount: canonicalAmount });
|
|
1435
|
+
createTestIntent({ amount: canonicalAmount });
|
|
1436
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1437
|
+
[SOLANA_CHAIN]: destinationDust,
|
|
1438
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1439
|
+
});
|
|
1440
|
+
let reverseQuoteTargetOutput;
|
|
1441
|
+
bridge.quote.callsFake(async (params) => {
|
|
1442
|
+
if (params.toAmount !== undefined) {
|
|
1443
|
+
reverseQuoteTargetOutput = params.toAmount;
|
|
1444
|
+
}
|
|
1445
|
+
return createMockBridgeQuote({
|
|
1446
|
+
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1447
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1448
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1449
|
+
requestParams: params.toAmount !== undefined
|
|
1450
|
+
? { ...params, toAmount: params.toAmount }
|
|
1451
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1452
|
+
});
|
|
1453
|
+
});
|
|
1454
|
+
bridge.execute.resolves({
|
|
1455
|
+
txHash: '0xBridgeTxHash',
|
|
1456
|
+
fromChain: 42161,
|
|
1457
|
+
toChain: 1399811149,
|
|
1458
|
+
});
|
|
1459
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1460
|
+
expect(results).to.have.lengthOf(1);
|
|
1461
|
+
expect(results[0].success).to.be.true;
|
|
1462
|
+
expect(warpCore.getTransferRemoteTxs.called).to.be.false;
|
|
1463
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
1464
|
+
expect(reverseQuoteTargetOutput).to.equal(1n);
|
|
1465
|
+
});
|
|
1466
|
+
it('plans LiFi target output in destination-local units for mixed-decimal routes', async () => {
|
|
1467
|
+
const canonicalAmount = 1000000n;
|
|
1468
|
+
const availableInventory = BigInt(2e18);
|
|
1469
|
+
warpCore.tokens.find((t) => t.chainName === SOLANA_CHAIN).scale = {
|
|
1470
|
+
numerator: 1n,
|
|
1471
|
+
denominator: 1000000000000n,
|
|
1472
|
+
};
|
|
1473
|
+
const route = createTestRoute({ amount: canonicalAmount });
|
|
1474
|
+
createTestIntent({ amount: canonicalAmount });
|
|
1475
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1476
|
+
[SOLANA_CHAIN]: 0n,
|
|
1477
|
+
[ARBITRUM_CHAIN]: availableInventory,
|
|
1478
|
+
});
|
|
1479
|
+
let quotedTargetOutput;
|
|
1480
|
+
bridge.quote.callsFake(async (params) => {
|
|
1481
|
+
if (params.toAmount !== undefined) {
|
|
1482
|
+
quotedTargetOutput = params.toAmount;
|
|
1483
|
+
}
|
|
1484
|
+
return createMockBridgeQuote({
|
|
1485
|
+
fromAmount: params.fromAmount ?? params.toAmount,
|
|
1486
|
+
toAmount: params.toAmount ?? params.fromAmount,
|
|
1487
|
+
toAmountMin: params.toAmount ?? params.fromAmount,
|
|
1488
|
+
requestParams: params.toAmount !== undefined
|
|
1489
|
+
? { ...params, toAmount: params.toAmount }
|
|
1490
|
+
: { ...params, fromAmount: params.fromAmount },
|
|
1491
|
+
});
|
|
1492
|
+
});
|
|
1493
|
+
bridge.execute.resolves({
|
|
1494
|
+
txHash: '0xBridgeTxHash',
|
|
1495
|
+
fromChain: 42161,
|
|
1496
|
+
toChain: 1399811149,
|
|
1497
|
+
});
|
|
1498
|
+
await inventoryRebalancer.rebalance([route]);
|
|
1499
|
+
expect(quotedTargetOutput).to.equal(1050000000000000000n);
|
|
1500
|
+
});
|
|
1501
|
+
it('retries forward execution when reverse quote exceeds planned source capacity', async () => {
|
|
1502
|
+
const amount = BigInt(1e18);
|
|
1503
|
+
const sourceInventory = BigInt(2e18);
|
|
1504
|
+
const targetWithBuffer = (amount * 105n) / 100n;
|
|
1505
|
+
const route = createTestRoute({ amount });
|
|
1506
|
+
createTestIntent({ amount });
|
|
1507
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1508
|
+
[SOLANA_CHAIN]: 0n,
|
|
1509
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1510
|
+
});
|
|
1511
|
+
bridge.quote
|
|
1512
|
+
.onFirstCall()
|
|
1513
|
+
.resolves(createMockBridgeQuote({
|
|
1514
|
+
fromAmount: sourceInventory,
|
|
1515
|
+
toAmount: sourceInventory,
|
|
1516
|
+
toAmountMin: sourceInventory,
|
|
1517
|
+
requestParams: {
|
|
1518
|
+
fromChain: 42161,
|
|
1519
|
+
toChain: 1399811149,
|
|
1520
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1521
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1522
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1523
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1524
|
+
fromAmount: sourceInventory,
|
|
1525
|
+
},
|
|
1526
|
+
}))
|
|
1527
|
+
.onSecondCall()
|
|
1528
|
+
.resolves(createMockBridgeQuote({
|
|
1529
|
+
fromAmount: sourceInventory + 1n,
|
|
1530
|
+
toAmount: targetWithBuffer,
|
|
1531
|
+
toAmountMin: targetWithBuffer,
|
|
1532
|
+
requestParams: {
|
|
1533
|
+
fromChain: 42161,
|
|
1534
|
+
toChain: 1399811149,
|
|
1535
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1536
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1537
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1538
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1539
|
+
toAmount: targetWithBuffer,
|
|
1540
|
+
},
|
|
1541
|
+
}))
|
|
1542
|
+
.onThirdCall()
|
|
1543
|
+
.resolves(createMockBridgeQuote({
|
|
1544
|
+
fromAmount: sourceInventory,
|
|
1545
|
+
toAmount: targetWithBuffer - 1n,
|
|
1546
|
+
toAmountMin: targetWithBuffer - 1n,
|
|
1547
|
+
requestParams: {
|
|
1548
|
+
fromChain: 42161,
|
|
1549
|
+
toChain: 1399811149,
|
|
1550
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1551
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1552
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1553
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1554
|
+
fromAmount: sourceInventory,
|
|
1555
|
+
},
|
|
1556
|
+
}));
|
|
1557
|
+
bridge.execute.resolves({
|
|
1558
|
+
txHash: '0xBridgeTxHash',
|
|
1559
|
+
fromChain: 42161,
|
|
1560
|
+
toChain: 1399811149,
|
|
1561
|
+
});
|
|
1562
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1563
|
+
expect(results).to.have.lengthOf(1);
|
|
1564
|
+
expect(results[0].success).to.be.true;
|
|
1565
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
1566
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
1567
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
|
|
1568
|
+
expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(sourceInventory);
|
|
1569
|
+
expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
|
|
1570
|
+
});
|
|
1571
|
+
it('fails when forward retry still exceeds planned source capacity', async () => {
|
|
1572
|
+
const amount = BigInt(1e18);
|
|
1573
|
+
const sourceInventory = BigInt(2e18);
|
|
1574
|
+
const targetWithBuffer = (amount * 105n) / 100n;
|
|
1575
|
+
const route = createTestRoute({ amount });
|
|
1576
|
+
createTestIntent({ amount });
|
|
1577
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1578
|
+
[SOLANA_CHAIN]: 0n,
|
|
1579
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1580
|
+
});
|
|
1581
|
+
bridge.quote
|
|
1582
|
+
.onFirstCall()
|
|
1583
|
+
.resolves(createMockBridgeQuote({
|
|
1584
|
+
fromAmount: sourceInventory,
|
|
1585
|
+
toAmount: sourceInventory,
|
|
1586
|
+
toAmountMin: sourceInventory,
|
|
1587
|
+
requestParams: {
|
|
1588
|
+
fromChain: 42161,
|
|
1589
|
+
toChain: 1399811149,
|
|
1590
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1591
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1592
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1593
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1594
|
+
fromAmount: sourceInventory,
|
|
1595
|
+
},
|
|
1596
|
+
}))
|
|
1597
|
+
.onSecondCall()
|
|
1598
|
+
.resolves(createMockBridgeQuote({
|
|
1599
|
+
fromAmount: sourceInventory + 1n,
|
|
1600
|
+
toAmount: targetWithBuffer,
|
|
1601
|
+
toAmountMin: targetWithBuffer,
|
|
1602
|
+
requestParams: {
|
|
1603
|
+
fromChain: 42161,
|
|
1604
|
+
toChain: 1399811149,
|
|
1605
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1606
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1607
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1608
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1609
|
+
toAmount: targetWithBuffer,
|
|
1610
|
+
},
|
|
1611
|
+
}))
|
|
1612
|
+
.onThirdCall()
|
|
1613
|
+
.resolves(createMockBridgeQuote({
|
|
1614
|
+
fromAmount: sourceInventory + 1n,
|
|
1615
|
+
toAmount: targetWithBuffer - 1n,
|
|
1616
|
+
toAmountMin: targetWithBuffer - 1n,
|
|
1617
|
+
requestParams: {
|
|
1618
|
+
fromChain: 42161,
|
|
1619
|
+
toChain: 1399811149,
|
|
1620
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1621
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1622
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1623
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1624
|
+
fromAmount: sourceInventory,
|
|
1625
|
+
},
|
|
1626
|
+
}));
|
|
1627
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1628
|
+
expect(results).to.have.lengthOf(1);
|
|
1629
|
+
expect(results[0].success).to.be.false;
|
|
1630
|
+
expect(results[0].error).to.include('All inventory movements failed');
|
|
1631
|
+
expect(results[0].error).to.include('exceeded planned source capacity');
|
|
1632
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
1633
|
+
expect(bridge.execute.called).to.be.false;
|
|
1634
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
|
|
1635
|
+
expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(sourceInventory);
|
|
1636
|
+
expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
|
|
1637
|
+
});
|
|
1638
|
+
it('fails gracefully when execute-time bridge quote rejects', async () => {
|
|
1639
|
+
const amount = BigInt(1e18);
|
|
1640
|
+
const sourceInventory = BigInt(2e18);
|
|
1641
|
+
const route = createTestRoute({ amount });
|
|
1642
|
+
createTestIntent({ amount });
|
|
1643
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1644
|
+
[SOLANA_CHAIN]: 0n,
|
|
1645
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1646
|
+
});
|
|
1647
|
+
bridge.quote
|
|
1648
|
+
.onFirstCall()
|
|
1649
|
+
.resolves(createMockBridgeQuote({
|
|
1650
|
+
fromAmount: sourceInventory,
|
|
1651
|
+
toAmount: sourceInventory,
|
|
1652
|
+
toAmountMin: sourceInventory,
|
|
1653
|
+
requestParams: {
|
|
1654
|
+
fromChain: 42161,
|
|
1655
|
+
toChain: 1399811149,
|
|
1656
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1657
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1658
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1659
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1660
|
+
fromAmount: sourceInventory,
|
|
1661
|
+
},
|
|
1662
|
+
}))
|
|
1663
|
+
.onSecondCall()
|
|
1664
|
+
.rejects(new Error('LiFi API timeout'));
|
|
1665
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1666
|
+
expect(results).to.have.lengthOf(1);
|
|
1667
|
+
expect(results[0].success).to.be.false;
|
|
1668
|
+
expect(results[0].error).to.include('All inventory movements failed');
|
|
1669
|
+
expect(results[0].error).to.include('LiFi API timeout');
|
|
1670
|
+
expect(bridge.quote.callCount).to.equal(2);
|
|
1671
|
+
expect(bridge.execute.called).to.be.false;
|
|
1672
|
+
});
|
|
1673
|
+
it('preserves non-Error rejection reasons from parallel bridge execution', async () => {
|
|
1674
|
+
const amount = BigInt(1e18);
|
|
1675
|
+
const sourceInventory = BigInt(2e18);
|
|
1676
|
+
const route = createTestRoute({ amount });
|
|
1677
|
+
createTestIntent({ amount });
|
|
1678
|
+
inventoryRebalancer.setInventoryBalances({
|
|
1679
|
+
[SOLANA_CHAIN]: 0n,
|
|
1680
|
+
[ARBITRUM_CHAIN]: sourceInventory,
|
|
1681
|
+
});
|
|
1682
|
+
bridge.quote.onFirstCall().resolves(createMockBridgeQuote({
|
|
1683
|
+
fromAmount: sourceInventory,
|
|
1684
|
+
toAmount: sourceInventory,
|
|
1685
|
+
toAmountMin: sourceInventory,
|
|
1686
|
+
requestParams: {
|
|
1687
|
+
fromChain: 42161,
|
|
1688
|
+
toChain: 1399811149,
|
|
1689
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1690
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1691
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1692
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1693
|
+
fromAmount: sourceInventory,
|
|
1694
|
+
},
|
|
1695
|
+
}));
|
|
1696
|
+
// Simulate a foreign promise rejecting with a non-Error reason at runtime.
|
|
1697
|
+
const rejectionReason = 'bridge exploded';
|
|
1698
|
+
const executeInventoryMovementStub = Sinon.stub(inventoryRebalancer, 'executeInventoryMovement').callsFake(() => Promise.resolve().then(() => {
|
|
1699
|
+
throw rejectionReason;
|
|
1700
|
+
}));
|
|
1701
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
1702
|
+
expect(results).to.have.lengthOf(1);
|
|
1703
|
+
expect(results[0].success).to.be.false;
|
|
1704
|
+
expect(results[0].error).to.include('All inventory movements failed');
|
|
1705
|
+
expect(results[0].error).to.include('arbitrum: bridge exploded');
|
|
1706
|
+
expect(executeInventoryMovementStub.calledOnce).to.be.true;
|
|
1707
|
+
expect(bridge.execute.called).to.be.false;
|
|
1708
|
+
executeInventoryMovementStub.restore();
|
|
1709
|
+
});
|
|
1710
|
+
it('logs actual successful bridge output floors instead of planned output totals', async () => {
|
|
1711
|
+
const amount = BigInt(1e18);
|
|
1712
|
+
const perChainInventory = BigInt(0.6e18);
|
|
1713
|
+
const logger = {
|
|
1714
|
+
info: Sinon.stub(),
|
|
1715
|
+
debug: Sinon.stub(),
|
|
1716
|
+
warn: Sinon.stub(),
|
|
1717
|
+
error: Sinon.stub(),
|
|
1718
|
+
};
|
|
1719
|
+
const localRebalancer = new InventoryRebalancer(config, actionTracker, { lifi: bridge }, warpCore, multiProvider, logger);
|
|
1720
|
+
const route = createTestRoute({ amount });
|
|
1721
|
+
createTestIntent({ amount });
|
|
1722
|
+
localRebalancer.setInventoryBalances({
|
|
1723
|
+
[SOLANA_CHAIN]: 0n,
|
|
1724
|
+
[ARBITRUM_CHAIN]: perChainInventory,
|
|
1725
|
+
[BASE_CHAIN]: perChainInventory,
|
|
1726
|
+
});
|
|
1727
|
+
bridge.quote
|
|
1728
|
+
.onCall(0)
|
|
1729
|
+
.resolves(createMockBridgeQuote({
|
|
1730
|
+
fromAmount: perChainInventory,
|
|
1731
|
+
toAmount: BigInt(0.525e18),
|
|
1732
|
+
toAmountMin: BigInt(0.525e18),
|
|
1733
|
+
}))
|
|
1734
|
+
.onCall(1)
|
|
1735
|
+
.resolves(createMockBridgeQuote({
|
|
1736
|
+
fromAmount: perChainInventory,
|
|
1737
|
+
toAmount: BigInt(0.525e18),
|
|
1738
|
+
toAmountMin: BigInt(0.525e18),
|
|
1739
|
+
}))
|
|
1740
|
+
.onCall(2)
|
|
1741
|
+
.resolves(createMockBridgeQuote({
|
|
1742
|
+
fromAmount: perChainInventory,
|
|
1743
|
+
toAmount: BigInt(0.505e18),
|
|
1744
|
+
toAmountMin: BigInt(0.5e18),
|
|
1745
|
+
}))
|
|
1746
|
+
.onCall(3)
|
|
1747
|
+
.resolves(createMockBridgeQuote({
|
|
1748
|
+
fromAmount: perChainInventory,
|
|
1749
|
+
toAmount: BigInt(0.515e18),
|
|
1750
|
+
toAmountMin: BigInt(0.51e18),
|
|
1751
|
+
}));
|
|
1752
|
+
bridge.execute
|
|
1753
|
+
.onFirstCall()
|
|
1754
|
+
.resolves({
|
|
1755
|
+
txHash: '0xSuccessTxHash1',
|
|
1756
|
+
fromChain: 42161,
|
|
1757
|
+
toChain: 1399811149,
|
|
1758
|
+
})
|
|
1759
|
+
.onSecondCall()
|
|
1760
|
+
.resolves({
|
|
1761
|
+
txHash: '0xSuccessTxHash2',
|
|
1762
|
+
fromChain: 8453,
|
|
1763
|
+
toChain: 1399811149,
|
|
1764
|
+
});
|
|
1765
|
+
const results = await localRebalancer.rebalance([route]);
|
|
1766
|
+
expect(results).to.have.lengthOf(1);
|
|
1767
|
+
expect(results[0].success).to.be.true;
|
|
1768
|
+
expect(bridge.execute.callCount).to.equal(2);
|
|
1769
|
+
const summaryCall = logger.info
|
|
1770
|
+
.getCalls()
|
|
1771
|
+
.find((call) => call.args[0] &&
|
|
1772
|
+
typeof call.args[0] === 'object' &&
|
|
1773
|
+
Object.prototype.hasOwnProperty.call(call.args[0], 'totalQuotedOutputMin'));
|
|
1774
|
+
assert(summaryCall, 'Expected summary log with totalQuotedOutputMin');
|
|
1775
|
+
expect(summaryCall.args[0].totalQuotedOutputMin).to.equal(BigInt(1.01e18).toString());
|
|
1225
1776
|
});
|
|
1226
1777
|
it('continues when some bridges fail', async () => {
|
|
1227
1778
|
const amount = BigInt(1e18);
|
|
@@ -1402,11 +1953,31 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1402
1953
|
[ARBITRUM_CHAIN]: tokenBalance,
|
|
1403
1954
|
});
|
|
1404
1955
|
// Quote with high gas costs (but for ERC20, this shouldn't trigger viability check)
|
|
1405
|
-
bridge.quote
|
|
1406
|
-
|
|
1407
|
-
|
|
1956
|
+
bridge.quote
|
|
1957
|
+
.onFirstCall()
|
|
1958
|
+
.resolves(createMockBridgeQuote({
|
|
1959
|
+
fromAmount: tokenBalance,
|
|
1960
|
+
toAmount: tokenBalance,
|
|
1961
|
+
toAmountMin: tokenBalance,
|
|
1408
1962
|
gasCosts: BigInt(1e18), // Very high gas (would fail viability if this were native)
|
|
1409
1963
|
feeCosts: 0n,
|
|
1964
|
+
}))
|
|
1965
|
+
.onSecondCall()
|
|
1966
|
+
.resolves(createMockBridgeQuote({
|
|
1967
|
+
fromAmount: tokenBalance,
|
|
1968
|
+
toAmount: tokenBalance,
|
|
1969
|
+
toAmountMin: tokenBalance,
|
|
1970
|
+
gasCosts: BigInt(1e18),
|
|
1971
|
+
feeCosts: 0n,
|
|
1972
|
+
requestParams: {
|
|
1973
|
+
fromChain: 42161,
|
|
1974
|
+
toChain: 1399811149,
|
|
1975
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1976
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
1977
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1978
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
1979
|
+
toAmount: tokenBalance,
|
|
1980
|
+
},
|
|
1410
1981
|
}));
|
|
1411
1982
|
bridge.execute.resolves({
|
|
1412
1983
|
txHash: '0xERC20BridgeTxHash',
|
|
@@ -1418,6 +1989,9 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1418
1989
|
expect(results).to.have.lengthOf(1);
|
|
1419
1990
|
expect(results[0].success).to.be.true;
|
|
1420
1991
|
expect(bridge.execute.calledOnce).to.be.true;
|
|
1992
|
+
expect(bridge.quote.callCount).to.equal(2);
|
|
1993
|
+
expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(tokenBalance);
|
|
1994
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
|
|
1421
1995
|
});
|
|
1422
1996
|
it('calculates max viable amount as inventory minus (gasCosts * 20) for native tokens', async () => {
|
|
1423
1997
|
// Setup: Native token with enough balance for viable bridge
|
|
@@ -1447,11 +2021,40 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1447
2021
|
// maxViable = 1 ETH - 0.02 ETH = 0.98 ETH
|
|
1448
2022
|
// 10% threshold = 0.1 ETH, estimatedGas (0.02) < threshold → viable
|
|
1449
2023
|
const gasCosts = BigInt(0.001e18); // 0.001 ETH
|
|
1450
|
-
|
|
2024
|
+
const maxViable = rawBalance - gasCosts * 20n;
|
|
2025
|
+
bridge.quote
|
|
2026
|
+
.onFirstCall()
|
|
2027
|
+
.resolves(createMockBridgeQuote({
|
|
1451
2028
|
fromAmount: rawBalance,
|
|
1452
2029
|
toAmount: rawBalance - BigInt(1e15),
|
|
2030
|
+
toAmountMin: rawBalance - BigInt(1e15),
|
|
2031
|
+
gasCosts,
|
|
2032
|
+
feeCosts: 0n,
|
|
2033
|
+
}))
|
|
2034
|
+
.onSecondCall()
|
|
2035
|
+
.resolves(createMockBridgeQuote({
|
|
2036
|
+
fromAmount: maxViable,
|
|
2037
|
+
toAmount: maxViable - BigInt(1e15),
|
|
2038
|
+
toAmountMin: maxViable - BigInt(1e15),
|
|
1453
2039
|
gasCosts,
|
|
1454
2040
|
feeCosts: 0n,
|
|
2041
|
+
}))
|
|
2042
|
+
.onThirdCall()
|
|
2043
|
+
.resolves(createMockBridgeQuote({
|
|
2044
|
+
fromAmount: BigInt(0.525e18),
|
|
2045
|
+
toAmount: BigInt(0.525e18),
|
|
2046
|
+
toAmountMin: BigInt(0.525e18),
|
|
2047
|
+
gasCosts,
|
|
2048
|
+
feeCosts: 0n,
|
|
2049
|
+
requestParams: {
|
|
2050
|
+
fromChain: 42161,
|
|
2051
|
+
toChain: 1399811149,
|
|
2052
|
+
fromToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2053
|
+
toToken: '0xCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
|
|
2054
|
+
fromAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2055
|
+
toAddress: '0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
|
|
2056
|
+
toAmount: BigInt(0.525e18),
|
|
2057
|
+
},
|
|
1455
2058
|
}));
|
|
1456
2059
|
bridge.execute.resolves({
|
|
1457
2060
|
txHash: '0xSuccessBridgeTxHash',
|
|
@@ -1463,15 +2066,94 @@ describe('InventoryRebalancer E2E', () => {
|
|
|
1463
2066
|
expect(results).to.have.lengthOf(1);
|
|
1464
2067
|
expect(results[0].success).to.be.true;
|
|
1465
2068
|
expect(bridge.execute.calledOnce).to.be.true;
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
expect(
|
|
2069
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
2070
|
+
expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(maxViable);
|
|
2071
|
+
const executionTargetOutput = bridge.quote.getCall(2).args[0].toAmount;
|
|
2072
|
+
expect(executionTargetOutput).to.be.a('bigint');
|
|
2073
|
+
if (executionTargetOutput === undefined) {
|
|
2074
|
+
throw new Error('Expected reverse quote to set toAmount');
|
|
2075
|
+
}
|
|
2076
|
+
expect(executionTargetOutput > amount).to.be.true;
|
|
2077
|
+
expect(executionTargetOutput <= maxViable).to.be.true;
|
|
2078
|
+
});
|
|
2079
|
+
it('uses forward quote when target output exactly matches source capacity', async () => {
|
|
2080
|
+
const amount = 1000n;
|
|
2081
|
+
const rawBalance = 1100n;
|
|
2082
|
+
const targetWithBuffer = 1050n;
|
|
2083
|
+
const route = createTestRoute({ amount });
|
|
2084
|
+
createTestIntent({ amount });
|
|
2085
|
+
inventoryRebalancer.setInventoryBalances({
|
|
2086
|
+
[SOLANA_CHAIN]: 0n,
|
|
2087
|
+
[ARBITRUM_CHAIN]: rawBalance,
|
|
2088
|
+
});
|
|
2089
|
+
bridge.quote
|
|
2090
|
+
.onFirstCall()
|
|
2091
|
+
.resolves(createMockBridgeQuote({
|
|
2092
|
+
fromAmount: rawBalance,
|
|
2093
|
+
toAmount: targetWithBuffer,
|
|
2094
|
+
toAmountMin: targetWithBuffer,
|
|
2095
|
+
}))
|
|
2096
|
+
.onSecondCall()
|
|
2097
|
+
.resolves(createMockBridgeQuote({
|
|
2098
|
+
fromAmount: rawBalance,
|
|
2099
|
+
toAmount: targetWithBuffer,
|
|
2100
|
+
toAmountMin: targetWithBuffer,
|
|
2101
|
+
}));
|
|
2102
|
+
bridge.execute.resolves({
|
|
2103
|
+
txHash: '0xForwardBoundaryBridgeTxHash',
|
|
2104
|
+
fromChain: 42161,
|
|
2105
|
+
toChain: 1399811149,
|
|
2106
|
+
});
|
|
2107
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
2108
|
+
expect(results).to.have.lengthOf(1);
|
|
2109
|
+
expect(results[0].success).to.be.true;
|
|
2110
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
2111
|
+
expect(bridge.quote.callCount).to.equal(2);
|
|
2112
|
+
expect(bridge.quote.getCall(1).args[0].fromAmount).to.equal(rawBalance);
|
|
2113
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.be.undefined;
|
|
2114
|
+
});
|
|
2115
|
+
it('retries reverse quote as forward when exact-output exceeds source capacity', async () => {
|
|
2116
|
+
const amount = 1000n;
|
|
2117
|
+
const rawBalance = 1100n;
|
|
2118
|
+
const targetWithBuffer = 1050n;
|
|
2119
|
+
const route = createTestRoute({ amount });
|
|
2120
|
+
createTestIntent({ amount });
|
|
2121
|
+
inventoryRebalancer.setInventoryBalances({
|
|
2122
|
+
[SOLANA_CHAIN]: 0n,
|
|
2123
|
+
[ARBITRUM_CHAIN]: rawBalance,
|
|
2124
|
+
});
|
|
2125
|
+
bridge.quote
|
|
2126
|
+
.onFirstCall()
|
|
2127
|
+
.resolves(createMockBridgeQuote({
|
|
2128
|
+
fromAmount: rawBalance,
|
|
2129
|
+
toAmount: 1051n,
|
|
2130
|
+
toAmountMin: 1051n,
|
|
2131
|
+
}))
|
|
2132
|
+
.onSecondCall()
|
|
2133
|
+
.resolves(createMockBridgeQuote({
|
|
2134
|
+
fromAmount: rawBalance + 1n,
|
|
2135
|
+
toAmount: targetWithBuffer,
|
|
2136
|
+
toAmountMin: targetWithBuffer,
|
|
2137
|
+
}))
|
|
2138
|
+
.onThirdCall()
|
|
2139
|
+
.resolves(createMockBridgeQuote({
|
|
2140
|
+
fromAmount: rawBalance,
|
|
2141
|
+
toAmount: 1049n,
|
|
2142
|
+
toAmountMin: 1049n,
|
|
2143
|
+
}));
|
|
2144
|
+
bridge.execute.resolves({
|
|
2145
|
+
txHash: '0xForwardFallbackBridgeTxHash',
|
|
2146
|
+
fromChain: 42161,
|
|
2147
|
+
toChain: 1399811149,
|
|
2148
|
+
});
|
|
2149
|
+
const results = await inventoryRebalancer.rebalance([route]);
|
|
2150
|
+
expect(results).to.have.lengthOf(1);
|
|
2151
|
+
expect(results[0].success).to.be.true;
|
|
2152
|
+
expect(bridge.execute.calledOnce).to.be.true;
|
|
2153
|
+
expect(bridge.quote.callCount).to.equal(3);
|
|
2154
|
+
expect(bridge.quote.getCall(1).args[0].toAmount).to.equal(targetWithBuffer);
|
|
2155
|
+
expect(bridge.quote.getCall(2).args[0].fromAmount).to.equal(rawBalance);
|
|
2156
|
+
expect(bridge.quote.getCall(2).args[0].toAmount).to.be.undefined;
|
|
1475
2157
|
});
|
|
1476
2158
|
it('handles quote failures gracefully by skipping the source chain', async () => {
|
|
1477
2159
|
// Setup: Native token where quote fails
|