@circle-fin/app-kit 1.8.1 → 1.10.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +3 -3
  3. package/bridge.cjs +1102 -260
  4. package/bridge.d.cts +161 -10
  5. package/bridge.d.mts +161 -10
  6. package/bridge.d.ts +161 -10
  7. package/bridge.mjs +1102 -260
  8. package/chains.cjs +102 -2
  9. package/chains.d.cts +3 -0
  10. package/chains.d.mts +3 -0
  11. package/chains.d.ts +3 -0
  12. package/chains.mjs +102 -2
  13. package/context.cjs +1 -0
  14. package/context.d.cts +166 -12
  15. package/context.d.mts +166 -12
  16. package/context.d.ts +166 -12
  17. package/context.mjs +1 -0
  18. package/earn.cjs +1074 -454
  19. package/earn.d.cts +546 -99
  20. package/earn.d.mts +546 -99
  21. package/earn.d.ts +546 -99
  22. package/earn.mjs +1074 -455
  23. package/estimateBridge.cjs +1102 -260
  24. package/estimateBridge.d.cts +161 -10
  25. package/estimateBridge.d.mts +161 -10
  26. package/estimateBridge.d.ts +161 -10
  27. package/estimateBridge.mjs +1102 -260
  28. package/estimateSwap.cjs +915 -96
  29. package/estimateSwap.d.cts +161 -10
  30. package/estimateSwap.d.mts +161 -10
  31. package/estimateSwap.d.ts +161 -10
  32. package/estimateSwap.mjs +915 -96
  33. package/index.cjs +3029 -862
  34. package/index.d.cts +1277 -143
  35. package/index.d.mts +1277 -143
  36. package/index.d.ts +1277 -143
  37. package/index.mjs +3029 -862
  38. package/package.json +12 -6
  39. package/swap.cjs +915 -96
  40. package/swap.d.cts +161 -10
  41. package/swap.d.mts +161 -10
  42. package/swap.d.ts +161 -10
  43. package/swap.mjs +915 -96
  44. package/unifiedBalance.cjs +822 -115
  45. package/unifiedBalance.d.cts +224 -4
  46. package/unifiedBalance.d.mts +224 -4
  47. package/unifiedBalance.d.ts +224 -4
  48. package/unifiedBalance.mjs +822 -115
@@ -2237,6 +2237,8 @@ class KitError extends Error {
2237
2237
  Blockchain["Celo_Alfajores_Testnet"] = "Celo_Alfajores_Testnet";
2238
2238
  Blockchain["Codex"] = "Codex";
2239
2239
  Blockchain["Codex_Testnet"] = "Codex_Testnet";
2240
+ Blockchain["Cronos"] = "Cronos";
2241
+ Blockchain["Cronos_Testnet"] = "Cronos_Testnet";
2240
2242
  Blockchain["Edge"] = "Edge";
2241
2243
  Blockchain["Edge_Testnet"] = "Edge_Testnet";
2242
2244
  Blockchain["Ethereum"] = "Ethereum";
@@ -2319,6 +2321,7 @@ var BridgeChain;
2319
2321
  BridgeChain["Avalanche"] = "Avalanche";
2320
2322
  BridgeChain["Base"] = "Base";
2321
2323
  BridgeChain["Codex"] = "Codex";
2324
+ BridgeChain["Cronos"] = "Cronos";
2322
2325
  BridgeChain["Edge"] = "Edge";
2323
2326
  BridgeChain["Ethereum"] = "Ethereum";
2324
2327
  BridgeChain["HyperEVM"] = "HyperEVM";
@@ -2343,6 +2346,7 @@ var BridgeChain;
2343
2346
  BridgeChain["Avalanche_Fuji"] = "Avalanche_Fuji";
2344
2347
  BridgeChain["Base_Sepolia"] = "Base_Sepolia";
2345
2348
  BridgeChain["Codex_Testnet"] = "Codex_Testnet";
2349
+ BridgeChain["Cronos_Testnet"] = "Cronos_Testnet";
2346
2350
  BridgeChain["Edge_Testnet"] = "Edge_Testnet";
2347
2351
  BridgeChain["Ethereum_Sepolia"] = "Ethereum_Sepolia";
2348
2352
  BridgeChain["HyperEVM_Testnet"] = "HyperEVM_Testnet";
@@ -2864,7 +2868,10 @@ var EarnChain;
2864
2868
  contracts: {
2865
2869
  v1: {
2866
2870
  wallet: GATEWAY_WALLET_EVM_TESTNET,
2867
- minter: GATEWAY_MINTER_EVM_TESTNET
2871
+ minter: GATEWAY_MINTER_EVM_TESTNET,
2872
+ // DepositForHandler the GenericExecutor calls to run a fast cross-chain
2873
+ // deposit into the GatewayWallet above.
2874
+ depositForHandler: '0xD05E7D2E7d30b92c5F17d7d0fC575fce231F1A48'
2868
2875
  }
2869
2876
  },
2870
2877
  forwarderSupported: {
@@ -3398,6 +3405,96 @@ var EarnChain;
3398
3405
  }
3399
3406
  });
3400
3407
 
3408
+ /**
3409
+ * Cronos Mainnet chain definition
3410
+ * @remarks
3411
+ * This represents the official production network for the Cronos blockchain.
3412
+ * Cronos is an EVM-compatible blockchain.
3413
+ */ const Cronos = defineChain({
3414
+ type: 'evm',
3415
+ chain: Blockchain.Cronos,
3416
+ name: 'Cronos',
3417
+ title: 'Cronos Mainnet',
3418
+ nativeCurrency: {
3419
+ name: 'Cronos',
3420
+ symbol: 'CRO',
3421
+ decimals: 18
3422
+ },
3423
+ chainId: 25,
3424
+ isTestnet: false,
3425
+ explorerUrl: 'https://cronoscan.com/tx/{hash}',
3426
+ rpcEndpoints: [
3427
+ 'https://evm.cronos.org'
3428
+ ],
3429
+ eurcAddress: '0xA6dE01a2d62C6B5f3525d768f34d276652C554c8',
3430
+ usdcAddress: '0x3D7F2C478aAfdB65542BCB44bCeeC05849999d2D',
3431
+ usdtAddress: null,
3432
+ cctp: {
3433
+ domain: 32,
3434
+ contracts: {
3435
+ v2: {
3436
+ type: 'split',
3437
+ tokenMessenger: '0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d',
3438
+ messageTransmitter: '0x81D40F21F12A8F0E3252Bccb954D722d4c464B64',
3439
+ confirmations: 1,
3440
+ fastConfirmations: 1
3441
+ }
3442
+ },
3443
+ forwarderSupported: {
3444
+ source: false,
3445
+ destination: false
3446
+ }
3447
+ },
3448
+ kitContracts: {
3449
+ bridge: BRIDGE_CONTRACT_EVM_MAINNET
3450
+ }
3451
+ });
3452
+
3453
+ /**
3454
+ * Cronos Testnet chain definition
3455
+ * @remarks
3456
+ * This represents the official test network for the Cronos blockchain.
3457
+ * Cronos is an EVM-compatible blockchain.
3458
+ */ const CronosTestnet = defineChain({
3459
+ type: 'evm',
3460
+ chain: Blockchain.Cronos_Testnet,
3461
+ name: 'Cronos Testnet',
3462
+ title: 'Cronos Testnet',
3463
+ nativeCurrency: {
3464
+ name: 'CRO',
3465
+ symbol: 'tCRO',
3466
+ decimals: 18
3467
+ },
3468
+ chainId: 338,
3469
+ isTestnet: true,
3470
+ explorerUrl: 'https://explorer.cronos.org/testnet/tx/{hash}',
3471
+ rpcEndpoints: [
3472
+ 'https://evm-t3.cronos.org'
3473
+ ],
3474
+ eurcAddress: '0x31f7538adb53cF16350e6B0c89d03D91b7D12c46',
3475
+ usdcAddress: '0xEb33dc5fac03833e132593659e1dE7256aB59794',
3476
+ usdtAddress: null,
3477
+ cctp: {
3478
+ domain: 32,
3479
+ contracts: {
3480
+ v2: {
3481
+ type: 'split',
3482
+ tokenMessenger: '0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA',
3483
+ messageTransmitter: '0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275',
3484
+ confirmations: 1,
3485
+ fastConfirmations: 1
3486
+ }
3487
+ },
3488
+ forwarderSupported: {
3489
+ source: false,
3490
+ destination: false
3491
+ }
3492
+ },
3493
+ kitContracts: {
3494
+ bridge: BRIDGE_CONTRACT_EVM_TESTNET
3495
+ }
3496
+ });
3497
+
3401
3498
  /**
3402
3499
  * Edge Mainnet chain definition
3403
3500
  * @remarks
@@ -5749,6 +5846,8 @@ var Chains = /*#__PURE__*/Object.freeze({
5749
5846
  CeloAlfajoresTestnet: CeloAlfajoresTestnet,
5750
5847
  Codex: Codex,
5751
5848
  CodexTestnet: CodexTestnet,
5849
+ Cronos: Cronos,
5850
+ CronosTestnet: CronosTestnet,
5752
5851
  Edge: Edge,
5753
5852
  EdgeTestnet: EdgeTestnet,
5754
5853
  Ethereum: Ethereum,
@@ -5856,7 +5955,10 @@ var Chains = /*#__PURE__*/Object.freeze({
5856
5955
  minter: z.string({
5857
5956
  required_error: 'Gateway minter address is required. Please provide a valid contract address.',
5858
5957
  invalid_type_error: 'Gateway minter address must be a string.'
5859
- }).min(1, 'Gateway minter address cannot be empty.')
5958
+ }).min(1, 'Gateway minter address cannot be empty.'),
5959
+ depositForHandler: z.string({
5960
+ invalid_type_error: 'Gateway depositForHandler address must be a string.'
5961
+ }).min(1, 'Gateway depositForHandler address cannot be empty.').optional()
5860
5962
  }).strict() // Reject any additional properties not defined in the schema
5861
5963
  ;
5862
5964
  /**
@@ -7722,6 +7824,7 @@ function parseOrThrow(value, schema, context) {
7722
7824
  [Blockchain.Base]: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
7723
7825
  [Blockchain.Celo]: '0xcebA9300f2b948710d2653dD7B07f33A8B32118C',
7724
7826
  [Blockchain.Codex]: '0xd996633a415985DBd7D6D12f4A4343E31f5037cf',
7827
+ [Blockchain.Cronos]: '0x3D7F2C478aAfdB65542BCB44bCeeC05849999d2D',
7725
7828
  [Blockchain.Edge]: '0x98d2919b9A214E6Fa5384AC81E6864bA686Ad74c',
7726
7829
  [Blockchain.Ethereum]: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
7727
7830
  [Blockchain.Hedera]: '0.0.456858',
@@ -7755,6 +7858,7 @@ function parseOrThrow(value, schema, context) {
7755
7858
  [Blockchain.Avalanche_Fuji]: '0x5425890298aed601595a70AB815c96711a31Bc65',
7756
7859
  [Blockchain.Base_Sepolia]: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
7757
7860
  [Blockchain.Codex_Testnet]: '0x6d7f141b6819C2c9CC2f818e6ad549E7Ca090F8f',
7861
+ [Blockchain.Cronos_Testnet]: '0xEb33dc5fac03833e132593659e1dE7256aB59794',
7758
7862
  [Blockchain.Edge_Testnet]: '0x2d9F7CAD728051AA35Ecdc472a14cf8cDF5CFD6B',
7759
7863
  [Blockchain.Ethereum_Sepolia]: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238',
7760
7864
  [Blockchain.Hedera_Testnet]: '0.0.429274',
@@ -7827,6 +7931,7 @@ function parseOrThrow(value, schema, context) {
7827
7931
  // =========================================================================
7828
7932
  [Blockchain.Avalanche]: '0xc891EB4cbdEFf6e073e859e987815Ed1505c2ACD',
7829
7933
  [Blockchain.Base]: '0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42',
7934
+ [Blockchain.Cronos]: '0xA6dE01a2d62C6B5f3525d768f34d276652C554c8',
7830
7935
  [Blockchain.Ethereum]: '0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c',
7831
7936
  [Blockchain.Solana]: 'HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr',
7832
7937
  [Blockchain.World_Chain]: '0x1C60ba0A0eD1019e8Eb035E6daF4155A5cE2380B',
@@ -7835,6 +7940,7 @@ function parseOrThrow(value, schema, context) {
7835
7940
  // =========================================================================
7836
7941
  [Blockchain.Arc_Testnet]: '0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a',
7837
7942
  [Blockchain.Base_Sepolia]: '0x808456652fdb597867f38412077A9182bf77359F',
7943
+ [Blockchain.Cronos_Testnet]: '0x31f7538adb53cF16350e6B0c89d03D91b7D12c46',
7838
7944
  [Blockchain.Ethereum_Sepolia]: '0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4'
7839
7945
  }
7840
7946
  };
@@ -8639,7 +8745,7 @@ function parseOrThrow(value, schema, context) {
8639
8745
  }
8640
8746
 
8641
8747
  var name = "@circle-fin/unified-balance-kit";
8642
- var version = "1.2.1";
8748
+ var version = "1.3.0";
8643
8749
  var pkg = {
8644
8750
  name: name,
8645
8751
  version: version};
@@ -16275,29 +16381,33 @@ const CIRCLE_BPS_DIVISOR = 10_000n;
16275
16381
  const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16276
16382
  /**
16277
16383
  * Return the estimated Gateway gas fee for a chain in USDC atomic units.
16278
- * Falls back to a conservative 0.1 USDC for unlisted chains.
16384
+ * Prefers an entry in `overrides` (the real per-chain fee derived from a
16385
+ * prior estimate), then the static {@link GAS_FEE_BY_CHAIN} constant, and
16386
+ * finally a conservative 0.1 USDC fallback for unlisted chains.
16279
16387
  *
16280
16388
  * @param chain - The source blockchain.
16389
+ * @param overrides - Optional real per-chain gas fees keyed by chain.
16281
16390
  * @returns Gas fee in USDC atomic units.
16282
- */ function getGasFee(chain) {
16283
- return GAS_FEE_BY_CHAIN.get(chain) ?? DEFAULT_GAS_FEE;
16391
+ */ function getGasFee(chain, overrides) {
16392
+ return overrides?.get(chain) ?? GAS_FEE_BY_CHAIN.get(chain) ?? DEFAULT_GAS_FEE;
16284
16393
  }
16285
16394
  /**
16286
16395
  * Return the estimated forwarder fee for the destination chain
16287
16396
  * (service fee + destination gas fee).
16288
16397
  *
16289
16398
  * @param destinationChain - The mint destination chain.
16399
+ * @param overrides - Optional real per-chain gas fees keyed by chain.
16290
16400
  * @returns Forwarder fee in USDC atomic units.
16291
- */ function getForwarderFee(destinationChain) {
16292
- const destGas = getGasFee(destinationChain);
16401
+ */ function getForwarderFee(destinationChain, overrides) {
16402
+ const destGas = getGasFee(destinationChain, overrides);
16293
16403
  return FORWARDER_SERVICE_FEE + destGas;
16294
16404
  }
16295
16405
  /**
16296
16406
  * Estimate the fixed fees (gas + forwarder) and compute the maximum
16297
16407
  * amount that can be drawn from this chain for a single intent,
16298
16408
  * accounting for the 0.5 bps transfer fee if cross-chain.
16299
- */ function computeMaxDrawable(slot, forwarderFeeRemaining) {
16300
- const gasFee = getGasFee(slot.chain);
16409
+ */ function computeMaxDrawable(slot, forwarderFeeRemaining, overrides) {
16410
+ const gasFee = getGasFee(slot.chain, overrides);
16301
16411
  let fixedFees = gasFee;
16302
16412
  let forwarderFeeUsed = 0n;
16303
16413
  if (forwarderFeeRemaining > 0n) {
@@ -16329,14 +16439,14 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16329
16439
  * buffer), and returns the allocations for this pass.
16330
16440
  *
16331
16441
  * Mutates `slot.remaining` so the next pass sees reduced balances.
16332
- */ function greedyAllocate(slots, amount, destinationChain, useForwarder) {
16442
+ */ function greedyAllocate(slots, amount, destinationChain, useForwarder, overrides) {
16333
16443
  const result = [];
16334
16444
  let remaining = amount;
16335
- let forwarderFeeRemaining = useForwarder ? getForwarderFee(destinationChain) : 0n;
16445
+ let forwarderFeeRemaining = useForwarder ? getForwarderFee(destinationChain, overrides) : 0n;
16336
16446
  if (remaining <= 0n) return result;
16337
16447
  for (const slot of slots){
16338
16448
  if (remaining <= 0n) break;
16339
- const { drawable, gasFee, forwarderFeeUsed } = computeMaxDrawable(slot, forwarderFeeRemaining);
16449
+ const { drawable, gasFee, forwarderFeeUsed } = computeMaxDrawable(slot, forwarderFeeRemaining, overrides);
16340
16450
  if (drawable <= 0n) continue;
16341
16451
  // Greedy: take as much as we can from this chain
16342
16452
  const take = remaining < drawable ? remaining : drawable // NOSONAR: This is a false positive — Math.min() only accepts number, not bigint, so the ternary is the correct pattern here.
@@ -16435,7 +16545,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16435
16545
  // After this pass, slot.remaining reflects consumed capacity.
16436
16546
  // -----------------------------------------------------------------------
16437
16547
  const transferAmount = parseUnits(ctx.amountIn, USDC_DECIMALS);
16438
- const allocations = greedyAllocate(slots, transferAmount, ctx.destinationChain, ctx.useForwarder);
16548
+ const allocations = greedyAllocate(slots, transferAmount, ctx.destinationChain, ctx.useForwarder, ctx.gasFeeOverrides);
16439
16549
  assertFullyAllocated(allocations, transferAmount, ctx.amountIn);
16440
16550
  // -----------------------------------------------------------------------
16441
16551
  // 4. Phase 2 — Allocate developer fee
@@ -16443,7 +16553,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16443
16553
  // Same-chain first here too — if the destination chain still has
16444
16554
  // capacity, use it (same-chain fee intent = cheapest gas).
16445
16555
  // -----------------------------------------------------------------------
16446
- const developerFeeAllocations = greedyAllocate(slots, devFeeAmount, ctx.destinationChain, false);
16556
+ const developerFeeAllocations = greedyAllocate(slots, devFeeAmount, ctx.destinationChain, false, ctx.gasFeeOverrides);
16447
16557
  if (devFeeAmount > 0n) {
16448
16558
  assertFullyAllocated(developerFeeAllocations, devFeeAmount, formatUnits(devFeeAmount.toString(), USDC_DECIMALS));
16449
16559
  }
@@ -16452,7 +16562,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16452
16562
  // Again same ordering, same shared reduced balances.
16453
16563
  // Same-chain first for the same reason.
16454
16564
  // -----------------------------------------------------------------------
16455
- const circleFeeAllocations = greedyAllocate(slots, circleFeeAmount, ctx.destinationChain, false);
16565
+ const circleFeeAllocations = greedyAllocate(slots, circleFeeAmount, ctx.destinationChain, false, ctx.gasFeeOverrides);
16456
16566
  if (circleFeeAmount > 0n) {
16457
16567
  assertFullyAllocated(circleFeeAllocations, circleFeeAmount, formatUnits(circleFeeAmount.toString(), USDC_DECIMALS));
16458
16568
  }
@@ -16551,10 +16661,12 @@ const BPS_DIVISOR = 100_000n;
16551
16661
  *
16552
16662
  * Unlike `findChainNameByDomain` (which returns the display `name`),
16553
16663
  * this returns `chain.chain` — the enum identifier expected by
16554
- * {@link FeeAllocation}.
16664
+ * {@link FeeAllocation}. Returns `undefined` when no allocation covers the
16665
+ * domain so callers skip the intent rather than bucketing it under a
16666
+ * fabricated sentinel.
16555
16667
  */ function findBlockchainByDomain(domain, allocations) {
16556
16668
  const alloc = allocations.find((a)=>a.chain.gateway.domain === domain);
16557
- return alloc?.chain.chain ?? 'Unknown';
16669
+ return alloc?.chain.chain;
16558
16670
  }
16559
16671
  /**
16560
16672
  * Normalize any address/salt format to lowercase bytes32 hex.
@@ -16678,6 +16790,33 @@ const BPS_DIVISOR = 100_000n;
16678
16790
  };
16679
16791
  });
16680
16792
  }
16793
+ /**
16794
+ * Read an intent's transfer value as a BigInt, tolerating the string form
16795
+ * that can appear on estimate-response specs.
16796
+ */ function intentValue(intent) {
16797
+ const { value } = intent.spec;
16798
+ return typeof value === 'bigint' ? value : safeBigInt(String(value), 'spec.value');
16799
+ }
16800
+ /**
16801
+ * Split a single intent's `maxFee` into its transfer-fee and gas-fee
16802
+ * components.
16803
+ *
16804
+ * `transferFee = value * GATEWAY_TRANSFER_FEE_SCALED_BPS / BPS_DIVISOR`
16805
+ * `gasFee = maxFee - transferFee`
16806
+ *
16807
+ * Same-chain transfers (withdrawals) do not incur a transfer fee, so the
16808
+ * whole `maxFee` is gas. See {@link aggregateFeesByIntent} for the caveats
16809
+ * on re-deriving the split locally.
16810
+ */ function splitIntentFee(intent) {
16811
+ const { maxFee, spec } = intent;
16812
+ const isSameChain = spec.sourceDomain === spec.destinationDomain;
16813
+ const transferFee = isSameChain ? 0n : intentValue(intent) * GATEWAY_TRANSFER_FEE_SCALED_BPS / BPS_DIVISOR;
16814
+ const gasFee = maxFee > transferFee ? maxFee - transferFee : 0n;
16815
+ return {
16816
+ transferFee,
16817
+ gasFee
16818
+ };
16819
+ }
16681
16820
  /**
16682
16821
  * Decompose each intent's `maxFee` into a transfer fee and a gas fee,
16683
16822
  * then aggregate both by source chain.
@@ -16702,7 +16841,6 @@ const BPS_DIVISOR = 100_000n;
16702
16841
  * names from source domains.
16703
16842
  * @returns Per-chain and total transfer/gas fee breakdowns.
16704
16843
  */ function aggregateFeesByIntent(estimatedIntents, allocations) {
16705
- const transferFeeBps = GATEWAY_TRANSFER_FEE_SCALED_BPS;
16706
16844
  const transferFeeByChain = new Map();
16707
16845
  const gasFeeByChain = new Map();
16708
16846
  let totalTransferFee = 0n;
@@ -16710,18 +16848,18 @@ const BPS_DIVISOR = 100_000n;
16710
16848
  for (const intent of estimatedIntents){
16711
16849
  const { maxFee, spec } = intent;
16712
16850
  if (maxFee === 0n) continue;
16713
- const chainName = findBlockchainByDomain(spec.sourceDomain, allocations);
16714
- const value = typeof spec.value === 'bigint' ? spec.value : safeBigInt(String(spec.value), 'spec.value');
16715
- const isSameChain = spec.sourceDomain === spec.destinationDomain;
16716
- const transferFee = isSameChain ? 0n : value * transferFeeBps / BPS_DIVISOR;
16717
- const gasFee = maxFee > transferFee ? maxFee - transferFee : 0n;
16851
+ const { transferFee, gasFee } = splitIntentFee(intent);
16852
+ totalTransferFee += transferFee;
16853
+ totalGasFee += gasFee;
16854
+ // Totals stay complete even if a domain can't be resolved; only the
16855
+ // per-chain breakdown skips it rather than inventing a placeholder chain.
16856
+ const chain = findBlockchainByDomain(spec.sourceDomain, allocations);
16857
+ if (chain === undefined) continue;
16718
16858
  if (transferFee > 0n) {
16719
- totalTransferFee += transferFee;
16720
- transferFeeByChain.set(chainName, (transferFeeByChain.get(chainName) ?? 0n) + transferFee);
16859
+ transferFeeByChain.set(chain, (transferFeeByChain.get(chain) ?? 0n) + transferFee);
16721
16860
  }
16722
16861
  if (gasFee > 0n) {
16723
- totalGasFee += gasFee;
16724
- gasFeeByChain.set(chainName, (gasFeeByChain.get(chainName) ?? 0n) + gasFee);
16862
+ gasFeeByChain.set(chain, (gasFeeByChain.get(chain) ?? 0n) + gasFee);
16725
16863
  }
16726
16864
  }
16727
16865
  return {
@@ -16785,6 +16923,91 @@ const BPS_DIVISOR = 100_000n;
16785
16923
  }
16786
16924
  return fees;
16787
16925
  }
16926
+ /**
16927
+ * Derive the real per-chain Gateway gas fee from estimated intents, keyed by
16928
+ * source {@link Blockchain}.
16929
+ *
16930
+ * The value is the maximum single-intent gas fee observed on each chain
16931
+ * (`maxFee − transferFee`) — the amount `computeAutoAllocation` must reserve
16932
+ * per burn intent on that chain. Gas is (near) amount-independent, so every
16933
+ * intent on a chain pays roughly the same; taking the max is a conservative
16934
+ * choice for the multi-intent-per-chain case.
16935
+ *
16936
+ * Intended for `AutoAllocationContext.gasFeeOverrides` so the corrective
16937
+ * re-allocation pass reserves the API's real fee instead of the static
16938
+ * {@link GAS_FEE_BY_CHAIN} constant.
16939
+ *
16940
+ * @param estimatedIntents - Intents with `maxFee` from {@link parseEstimateResponse}.
16941
+ * @param allocations - Normalised allocations used to resolve chain from source domain.
16942
+ * @returns Per-chain real gas fees in USDC atomic units.
16943
+ *
16944
+ * @example
16945
+ * ```typescript
16946
+ * import type { BurnIntent } from '../createIntent/types'
16947
+ * import type { NormalizedAllocation } from '../allocations'
16948
+ *
16949
+ * declare const estimatedIntents: BurnIntent[]
16950
+ * declare const allocations: NormalizedAllocation[]
16951
+ *
16952
+ * // Real per-chain gas, ready to pass as AutoAllocationContext.gasFeeOverrides
16953
+ * // to re-run computeAutoAllocation with the corrected reserve.
16954
+ * const overrides = deriveGasFeeOverrides(estimatedIntents, allocations)
16955
+ * ```
16956
+ */ function deriveGasFeeOverrides(estimatedIntents, allocations) {
16957
+ const overrides = new Map();
16958
+ for (const intent of estimatedIntents){
16959
+ if (intent.maxFee === 0n) continue;
16960
+ const chain = findBlockchainByDomain(intent.spec.sourceDomain, allocations);
16961
+ if (chain === undefined) continue;
16962
+ const { gasFee } = splitIntentFee(intent);
16963
+ const prev = overrides.get(chain) ?? 0n;
16964
+ if (gasFee > prev) overrides.set(chain, gasFee);
16965
+ }
16966
+ return overrides;
16967
+ }
16968
+ /**
16969
+ * Sum the total balance each source chain must cover, keyed by source
16970
+ * {@link Blockchain}.
16971
+ *
16972
+ * Approximates the Gateway API's balance validation, which rejects a transfer
16973
+ * (`BALANCE_INSUFFICIENT_TOKEN`) when a depositor's confirmed balance on a
16974
+ * source chain is below `sum(intent.value + intent.maxFee)` for that
16975
+ * depositor's intents. This aggregates by chain across all sources, so it is
16976
+ * exact for the common single-depositor-per-chain wallet. When several
16977
+ * depositors hold USDC on the same chain, the chain-level sum can mask a
16978
+ * per-depositor shortfall (or a surplus on one depositor can hide it); the
16979
+ * API's own per-depositor `9001` remains the backstop for that case. Scope the
16980
+ * comparison per (depositor, chain) if that multi-depositor case must be caught
16981
+ * pre-submit.
16982
+ *
16983
+ * @param estimatedIntents - Intents with `maxFee` from {@link parseEstimateResponse}.
16984
+ * @param allocations - Normalised allocations used to resolve chain from source domain.
16985
+ * @returns Per-chain required amount (transfer value + fees) in USDC atomic units.
16986
+ *
16987
+ * @example
16988
+ * ```typescript
16989
+ * import { Blockchain } from '@core/chains'
16990
+ * import type { BurnIntent } from '../createIntent/types'
16991
+ * import type { NormalizedAllocation } from '../allocations'
16992
+ *
16993
+ * declare const estimatedIntents: BurnIntent[]
16994
+ * declare const allocations: NormalizedAllocation[]
16995
+ * declare const confirmedBalanceAtomic: bigint
16996
+ *
16997
+ * const required = sumRequiredPerChain(estimatedIntents, allocations)
16998
+ * const overDrawn =
16999
+ * (required.get(Blockchain.Ethereum) ?? 0n) > confirmedBalanceAtomic
17000
+ * ```
17001
+ */ function sumRequiredPerChain(estimatedIntents, allocations) {
17002
+ const required = new Map();
17003
+ for (const intent of estimatedIntents){
17004
+ const chain = findBlockchainByDomain(intent.spec.sourceDomain, allocations);
17005
+ if (chain === undefined) continue;
17006
+ const amount = intentValue(intent) + intent.maxFee;
17007
+ required.set(chain, (required.get(chain) ?? 0n) + amount);
17008
+ }
17009
+ return required;
17010
+ }
16788
17011
 
16789
17012
  /**
16790
17013
  * Sign each adapter group: Solana one intent per signature, EVM batch per adapter.
@@ -17039,69 +17262,127 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17039
17262
  * non-forwarder transfer response is missing attestation or signature.
17040
17263
  * @throws KitError Propagated from adapter signing if the user rejects
17041
17264
  * or the signer is unavailable.
17042
- */ async function resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder) {
17043
- if (params.amountIn) {
17044
- const rawSources = Array.isArray(params.from) ? params.from : [
17045
- params.from
17046
- ];
17047
- const sourcesArray = rawSources.filter((s)=>s != null);
17048
- const networkType = destChain.isTestnet ? 'testnet' : 'mainnet';
17049
- const balanceResults = await Promise.all(sourcesArray.map(async (source)=>{
17050
- // When sourceAccount is set (delegate flow), scope the balance
17051
- // query to the Gateway depositor not the signer. Using the
17052
- // address-only path bypasses adapter address resolution, which
17053
- // would otherwise return the signer's balance (developer-
17054
- // controlled) or reject an explicit address (user-controlled).
17055
- let querySource;
17056
- if (source.sourceAccount) {
17057
- querySource = {
17058
- address: source.sourceAccount
17059
- };
17060
- } else {
17061
- querySource = {
17062
- adapter: source.adapter
17063
- };
17064
- if ('address' in source && source.address) {
17065
- querySource['address'] = source.address;
17066
- }
17067
- }
17068
- return getBalances$1({
17069
- token: params.token,
17070
- sources: querySource,
17071
- networkType
17072
- });
17073
- }));
17074
- const chainBalances = [];
17075
- for(let i = 0; i < balanceResults.length; i++){
17076
- const breakdowns = balanceResults[i]?.breakdown[0]?.breakdown ?? [];
17077
- for (const b of breakdowns){
17078
- chainBalances.push({
17079
- chain: b.chain,
17080
- confirmedBalance: b.confirmedBalance,
17081
- sourceIndex: i
17082
- });
17265
+ */ /**
17266
+ * Fetch confirmed per-chain USDC balances for every auto-allocation source.
17267
+ *
17268
+ * Used only on the `amountIn` (auto-allocation) path. Returns one
17269
+ * {@link ChainBalance} per (source, chain) pair so the greedy allocator — and
17270
+ * the corrective re-allocation pass — can reason about draw limits without a
17271
+ * second balance round-trip.
17272
+ *
17273
+ * @param params - Spend parameters (source(s) and token).
17274
+ * @param destChain - Resolved destination chain (used for network type).
17275
+ * @returns Confirmed balances tagged with their originating source index.
17276
+ */ async function fetchChainBalances(params, destChain) {
17277
+ const rawSources = Array.isArray(params.from) ? params.from : [
17278
+ params.from
17279
+ ];
17280
+ const sourcesArray = rawSources.filter((s)=>s != null);
17281
+ const networkType = destChain.isTestnet ? 'testnet' : 'mainnet';
17282
+ const balanceResults = await Promise.all(sourcesArray.map(async (source)=>{
17283
+ // When sourceAccount is set (delegate flow), scope the balance
17284
+ // query to the Gateway depositor — not the signer. Using the
17285
+ // address-only path bypasses adapter address resolution, which
17286
+ // would otherwise return the signer's balance (developer-
17287
+ // controlled) or reject an explicit address (user-controlled).
17288
+ let querySource;
17289
+ if (source.sourceAccount) {
17290
+ querySource = {
17291
+ address: source.sourceAccount
17292
+ };
17293
+ } else {
17294
+ querySource = {
17295
+ adapter: source.adapter
17296
+ };
17297
+ if ('address' in source && source.address) {
17298
+ querySource['address'] = source.address;
17083
17299
  }
17084
17300
  }
17085
- const customFeeConfig = params.config?.customFee;
17086
- const autoAllocResult = computeAutoAllocation({
17087
- amountIn: params.amountIn,
17088
- destinationChain: destChain.chain,
17089
- chainBalances,
17090
- useForwarder,
17091
- ...customFeeConfig ? {
17092
- customFee: customFeeConfig
17093
- } : {}
17301
+ return getBalances$1({
17302
+ token: params.token,
17303
+ sources: querySource,
17304
+ networkType
17094
17305
  });
17095
- const normalizedAutoAllocations = await normalizeAutoAllocations(autoAllocResult, sourcesArray);
17096
- const intents = buildAutoAllocatedBurnIntents(normalizedAutoAllocations, destChain, recipientAddress, params.token, params.config?.customFee);
17097
- const allocations = [
17098
- ...normalizedAutoAllocations.user,
17099
- ...normalizedAutoAllocations.devFee,
17100
- ...normalizedAutoAllocations.circleFee
17101
- ];
17306
+ }));
17307
+ const chainBalances = [];
17308
+ for(let i = 0; i < balanceResults.length; i++){
17309
+ const breakdowns = balanceResults[i]?.breakdown[0]?.breakdown ?? [];
17310
+ for (const b of breakdowns){
17311
+ chainBalances.push({
17312
+ chain: b.chain,
17313
+ confirmedBalance: b.confirmedBalance,
17314
+ sourceIndex: i
17315
+ });
17316
+ }
17317
+ }
17318
+ return chainBalances;
17319
+ }
17320
+ /**
17321
+ * Build auto-allocated normalised allocations and burn intents from
17322
+ * pre-fetched balances.
17323
+ *
17324
+ * Performs no balance API call, so it can be re-invoked with
17325
+ * `gasFeeOverrides` (the real per-chain gas from a prior estimate) to correct
17326
+ * an over-draw without re-querying balances.
17327
+ *
17328
+ * @param params - Spend parameters (source(s), token, optional custom fee).
17329
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17330
+ * @param recipientAddress - Resolved recipient address on the destination chain.
17331
+ * @param useForwarder - Whether the Forwarding Service path is active.
17332
+ * @param amountIn - Human-readable USDC amount to allocate.
17333
+ * @param chainBalances - Confirmed balances from {@link fetchChainBalances}.
17334
+ * @param gasFeeOverrides - Optional real per-chain gas fees to reserve.
17335
+ * @returns Normalised allocations and burn intents for the estimate/transfer API.
17336
+ */ async function buildAutoAllocatedFromBalances(params, destChain, recipientAddress, useForwarder, amountIn, chainBalances, gasFeeOverrides) {
17337
+ const rawSources = Array.isArray(params.from) ? params.from : [
17338
+ params.from
17339
+ ];
17340
+ const sourcesArray = rawSources.filter((s)=>s != null);
17341
+ const customFeeConfig = params.config?.customFee;
17342
+ const autoAllocResult = computeAutoAllocation({
17343
+ amountIn,
17344
+ destinationChain: destChain.chain,
17345
+ chainBalances,
17346
+ useForwarder,
17347
+ ...customFeeConfig ? {
17348
+ customFee: customFeeConfig
17349
+ } : {},
17350
+ ...gasFeeOverrides ? {
17351
+ gasFeeOverrides
17352
+ } : {}
17353
+ });
17354
+ const normalizedAutoAllocations = await normalizeAutoAllocations(autoAllocResult, sourcesArray);
17355
+ const intents = buildAutoAllocatedBurnIntents(normalizedAutoAllocations, destChain, recipientAddress, params.token, params.config?.customFee);
17356
+ const allocations = [
17357
+ ...normalizedAutoAllocations.user,
17358
+ ...normalizedAutoAllocations.devFee,
17359
+ ...normalizedAutoAllocations.circleFee
17360
+ ];
17361
+ return {
17362
+ allocations,
17363
+ intents
17364
+ };
17365
+ }
17366
+ /**
17367
+ * Resolve allocations and burn intents for the spend.
17368
+ *
17369
+ * Auto-allocation (`amountIn`) fetches balances once and returns them so the
17370
+ * caller can detect and correct over-draw without re-querying. Explicit
17371
+ * allocations return no balances (they are user-authoritative).
17372
+ *
17373
+ * @param params - Spend parameters.
17374
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17375
+ * @param recipientAddress - Resolved recipient address.
17376
+ * @param useForwarder - Whether the Forwarding Service path is active.
17377
+ * @returns Allocations, intents, and (auto-allocation only) confirmed balances.
17378
+ */ async function resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder) {
17379
+ if (params.amountIn) {
17380
+ const chainBalances = await fetchChainBalances(params, destChain);
17381
+ const { allocations, intents } = await buildAutoAllocatedFromBalances(params, destChain, recipientAddress, useForwarder, params.amountIn, chainBalances);
17102
17382
  return {
17103
17383
  allocations,
17104
- intents
17384
+ intents,
17385
+ chainBalances
17105
17386
  };
17106
17387
  }
17107
17388
  const allocations = await normalizeAllocations(params);
@@ -17143,6 +17424,148 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17143
17424
  forwardingFee: undefined
17144
17425
  };
17145
17426
  }
17427
+ /** Sum confirmed balances (atomic USDC) per source chain. */ function computeAvailablePerChain(chainBalances) {
17428
+ const available = new Map();
17429
+ for (const b of chainBalances){
17430
+ const atomic = parseUnits(b.confirmedBalance, USDC_DECIMALS$1);
17431
+ available.set(b.chain, (available.get(b.chain) ?? 0n) + atomic);
17432
+ }
17433
+ return available;
17434
+ }
17435
+ /**
17436
+ * Detect source chains whose required draw (value + maxFee across their
17437
+ * intents) exceeds the confirmed balance — the condition the Gateway API
17438
+ * rejects with `BALANCE_INSUFFICIENT_TOKEN` at `/v1/transfer`.
17439
+ *
17440
+ * Both sides are summed per chain (see {@link sumRequiredPerChain} and
17441
+ * {@link computeAvailablePerChain}), so detection is exact for the common
17442
+ * single-depositor-per-chain wallet. When multiple depositors hold USDC on the
17443
+ * same chain, a chain-level surplus can mask a per-depositor shortfall; the
17444
+ * API's own per-depositor `9001` remains the backstop in that case.
17445
+ */ function findOverdrawnChains(estimatedIntents, allocations, chainBalances) {
17446
+ const required = sumRequiredPerChain(estimatedIntents, allocations);
17447
+ const available = computeAvailablePerChain(chainBalances);
17448
+ const overdrawn = [];
17449
+ for (const [chain, req] of required){
17450
+ const avail = available.get(chain) ?? 0n;
17451
+ if (req > avail) {
17452
+ overdrawn.push({
17453
+ chain,
17454
+ required: req,
17455
+ available: avail
17456
+ });
17457
+ }
17458
+ }
17459
+ return overdrawn;
17460
+ }
17461
+ /**
17462
+ * Build a descriptive KitError for an auto-allocation gas shortfall that
17463
+ * survives the corrective re-allocation, naming the per-chain gap so the
17464
+ * caller sees the real cause instead of the opaque API 9001 rejection.
17465
+ */ function createAutoAllocationGasError(overdrawn, cause) {
17466
+ const detail = overdrawn.map((o)=>`${String(o.chain)} needs ${formatUnits(o.required.toString(), USDC_DECIMALS$1)} USDC ` + `(transfer + gas) but only ${formatUnits(o.available.toString(), USDC_DECIMALS$1)} USDC is available`).join('; ');
17467
+ return new KitError({
17468
+ ...BalanceError.INSUFFICIENT_GAS,
17469
+ recoverability: 'FATAL',
17470
+ message: `Insufficient USDC to cover the transfer amount plus Gateway gas fees: ${detail}. ` + `Reduce the amount or add USDC on the affected chain(s).`,
17471
+ ...cause === undefined ? {} : {
17472
+ cause: {
17473
+ trace: {
17474
+ cause
17475
+ }
17476
+ }
17477
+ }
17478
+ });
17479
+ }
17480
+ /**
17481
+ * Validate allocations against the network/forwarder rules, call the estimate
17482
+ * API, and return the estimated intents with any forwarding fee.
17483
+ *
17484
+ * @param allocations - Normalised allocations for the estimate.
17485
+ * @param intents - Burn intents to estimate.
17486
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17487
+ * @param useForwarder - Whether the Forwarding Service path is active.
17488
+ * @returns Estimated intents (with real maxFee) and optional forwarding fee.
17489
+ */ async function validateAndEstimate(allocations, intents, destChain, useForwarder) {
17490
+ assertNetworkCompatibility(allocations, destChain);
17491
+ if (useForwarder) {
17492
+ assertForwarderRouteSupport(destChain, allocations);
17493
+ }
17494
+ const apiBaseUrl = getGatewayApiBaseUrl(destChain.isTestnet);
17495
+ const estimateBody = buildEstimateRequestBody(intents);
17496
+ const { entries, forwardingFee } = await fetchEstimate(apiBaseUrl, estimateBody, useForwarder, allocations);
17497
+ const estimatedIntents = parseEstimateResponse(entries, intents);
17498
+ return {
17499
+ estimatedIntents,
17500
+ forwardingFee
17501
+ };
17502
+ }
17503
+ /**
17504
+ * Fold newly-observed per-chain gas into the accumulated overrides, keeping the
17505
+ * higher fee per chain so a chain a later pass reveals is never under-reserved.
17506
+ */ function mergeGasFeeOverrides(base, next) {
17507
+ const merged = new Map(base);
17508
+ for (const [chain, fee] of next){
17509
+ const prev = merged.get(chain);
17510
+ if (prev === undefined || fee > prev) {
17511
+ merged.set(chain, fee);
17512
+ }
17513
+ }
17514
+ return merged;
17515
+ }
17516
+ /**
17517
+ * Maximum corrective re-allocation passes before failing fast. One pass fixes
17518
+ * the common case; a second/third covers a chain that a spill only introduces
17519
+ * after gas is reserved. Bounds the worst case at this many extra estimate
17520
+ * round-trips (only ever reached when the balance genuinely falls short).
17521
+ */ const MAX_CORRECTION_PASSES = 3;
17522
+ /**
17523
+ * Correct an auto-allocation over-draw: reserve the estimate's real per-chain
17524
+ * gas, re-allocate from the same balances, and re-estimate — repeating up to
17525
+ * {@link MAX_CORRECTION_PASSES} times, accumulating the real gas each pass
17526
+ * reveals.
17527
+ *
17528
+ * One pass fixes the common case, where the over-drawn chain was already in the
17529
+ * first estimate. A further pass covers a chain that a spill only introduces
17530
+ * once gas is reserved on the destination: that chain isn't in the first
17531
+ * estimate, so its real gas is unknown until it appears, and its first
17532
+ * re-allocation falls back to the static reserve. Each pass folds the newly
17533
+ * revealed gas into the overrides (see {@link mergeGasFeeOverrides}) so the
17534
+ * next pass reserves it too. Per-chain gas is ~amount-independent, so once
17535
+ * every drawn chain's real gas is known the allocation converges.
17536
+ *
17537
+ * When the shortfall is genuine — the re-allocation can't cover the amount, or
17538
+ * the passes are exhausted while still over-drawn — throws a gas-specific
17539
+ * {@link KitError} instead of submitting a doomed transfer.
17540
+ */ async function correctOverdraw(opts) {
17541
+ let overrides = deriveGasFeeOverrides(opts.estimatedIntents, opts.allocations);
17542
+ let overdrawn = opts.overdrawn;
17543
+ for(let pass = 0; pass < MAX_CORRECTION_PASSES; pass++){
17544
+ let corrected;
17545
+ try {
17546
+ corrected = await buildAutoAllocatedFromBalances(opts.params, opts.destChain, opts.recipientAddress, opts.useForwarder, opts.amountIn, opts.chainBalances, overrides);
17547
+ } catch (err) {
17548
+ // Re-allocating with the real gas reserved can't cover the amount →
17549
+ // surface a gas-specific error instead of the opaque API rejection.
17550
+ if (err instanceof KitError && err.code === BalanceError.INSUFFICIENT_TOKEN.code) {
17551
+ throw createAutoAllocationGasError(overdrawn, err);
17552
+ }
17553
+ throw err;
17554
+ }
17555
+ const { estimatedIntents, forwardingFee } = await validateAndEstimate(corrected.allocations, corrected.intents, opts.destChain, opts.useForwarder);
17556
+ const stillOverdrawn = findOverdrawnChains(estimatedIntents, corrected.allocations, opts.chainBalances);
17557
+ if (stillOverdrawn.length === 0) {
17558
+ return {
17559
+ allocations: corrected.allocations,
17560
+ estimatedIntents,
17561
+ forwardingFee
17562
+ };
17563
+ }
17564
+ overdrawn = stillOverdrawn;
17565
+ overrides = mergeGasFeeOverrides(overrides, deriveGasFeeOverrides(estimatedIntents, corrected.allocations));
17566
+ }
17567
+ throw createAutoAllocationGasError(overdrawn);
17568
+ }
17146
17569
  /**
17147
17570
  * Validate allocations, call the estimate API, and return estimated intents.
17148
17571
  *
@@ -17150,6 +17573,14 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17150
17573
  * path. Handles forwarder route validation, network compatibility, and the
17151
17574
  * estimate API call.
17152
17575
  *
17576
+ * For auto-allocation (`amountIn`), the greedy allocator reserves a static
17577
+ * per-chain gas fee that can undershoot the API's real fee, draining a source
17578
+ * (typically the destination chain) below `value + maxFee` and triggering a
17579
+ * `BALANCE_INSUFFICIENT_TOKEN` rejection. When the first estimate reveals such
17580
+ * an over-draw, a bounded corrective re-allocation reserves the real gas and
17581
+ * re-estimates until it converges or fails fast (see {@link correctOverdraw}).
17582
+ * Explicit allocations are user-authoritative and never re-allocated.
17583
+ *
17153
17584
  * @param params - Spend parameters.
17154
17585
  * @param destChain - Resolved destination chain with Gateway v1 config.
17155
17586
  * @param recipientAddress - Resolved recipient address.
@@ -17159,15 +17590,24 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17159
17590
  if (useForwarder) {
17160
17591
  assertForwarderRouteSupport(destChain);
17161
17592
  }
17162
- const { allocations, intents } = await resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder);
17163
- assertNetworkCompatibility(allocations, destChain);
17164
- if (useForwarder) {
17165
- assertForwarderRouteSupport(destChain, allocations);
17593
+ const { allocations, intents, chainBalances } = await resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder);
17594
+ const { estimatedIntents, forwardingFee } = await validateAndEstimate(allocations, intents, destChain, useForwarder);
17595
+ if (params.amountIn && chainBalances) {
17596
+ const overdrawn = findOverdrawnChains(estimatedIntents, allocations, chainBalances);
17597
+ if (overdrawn.length > 0) {
17598
+ return correctOverdraw({
17599
+ params,
17600
+ destChain,
17601
+ recipientAddress,
17602
+ useForwarder,
17603
+ amountIn: params.amountIn,
17604
+ chainBalances,
17605
+ estimatedIntents,
17606
+ allocations,
17607
+ overdrawn
17608
+ });
17609
+ }
17166
17610
  }
17167
- const apiBaseUrl = getGatewayApiBaseUrl(destChain.isTestnet);
17168
- const estimateBody = buildEstimateRequestBody(intents);
17169
- const { entries, forwardingFee } = await fetchEstimate(apiBaseUrl, estimateBody, useForwarder, allocations);
17170
- const estimatedIntents = parseEstimateResponse(entries, intents);
17171
17611
  return {
17172
17612
  allocations,
17173
17613
  estimatedIntents,
@@ -18075,8 +18515,10 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18075
18515
  *
18076
18516
  * - `computeFee` — required function that receives resolved spend params
18077
18517
  * and returns a fee as a string (or `Promise<string>`).
18078
- * - `resolveFeeRecipientAddress` — required function that returns a
18079
- * recipient address as a string (or `Promise<string>`).
18518
+ * - `resolveFeeRecipientAddress` — optional function that returns a
18519
+ * recipient address as a string (or `Promise<string>`). Omit it when
18520
+ * using `setFeeRecipients()`'s declarative map instead — a policy
18521
+ * with neither throws at spend time.
18080
18522
  *
18081
18523
  * @example
18082
18524
  * ```ts
@@ -18089,7 +18531,7 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18089
18531
  * ```
18090
18532
  */ const customFeePolicySchema = z.object({
18091
18533
  computeFee: z.function().returns(z.string().or(z.promise(z.string()))),
18092
- resolveFeeRecipientAddress: z.function().returns(z.string().or(z.promise(z.string())))
18534
+ resolveFeeRecipientAddress: z.function().returns(z.string().or(z.promise(z.string()))).optional()
18093
18535
  }).strict();
18094
18536
  /**
18095
18537
  * Assert that the provided value conforms to {@link CustomFeePolicy}.
@@ -18111,6 +18553,71 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18111
18553
  validateWithStateTracking(config, customFeePolicySchema, 'UnifiedBalanceKit custom fee policy', assertCustomFeePolicySymbol);
18112
18554
  }
18113
18555
 
18556
+ const assertFeeRecipientsConfigSymbol = Symbol('assertFeeRecipientsConfig');
18557
+ /**
18558
+ * Schema for validating {@link FeeRecipientsConfig}.
18559
+ *
18560
+ * Requires at least one of `evm`/`solana`, non-empty string values for
18561
+ * whichever keys are present, and — mirroring the `depositAccount`
18562
+ * validation in `deposit/validate/assertions` — an address format that
18563
+ * matches the given chain type (EVM hex vs Solana base58).
18564
+ *
18565
+ * @example
18566
+ * ```ts
18567
+ * const config = {
18568
+ * evm: '0x1234567890123456789012345678901234567890',
18569
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
18570
+ * }
18571
+ * const result = feeRecipientsConfigSchema.safeParse(config)
18572
+ * // result.success === true
18573
+ * ```
18574
+ */ const feeRecipientsConfigSchema = z.object({
18575
+ evm: z.string().min(1, 'Fee recipient address is required.').optional(),
18576
+ solana: z.string().min(1, 'Fee recipient address is required.').optional()
18577
+ }).strict().refine((config)=>Object.keys(config).length > 0, {
18578
+ message: 'At least one fee recipient (evm or solana) is required.'
18579
+ }).superRefine((config, ctx)=>{
18580
+ for (const type of Object.keys(config)){
18581
+ const address = config[type];
18582
+ if (address == null) continue;
18583
+ // `{ name: type, type }` is a placeholder chain identifier — only
18584
+ // `.type` is checked by these two helpers today, `.name` is unused.
18585
+ // No real ChainDefinition exists here, since validation runs before
18586
+ // a destination chain is resolved.
18587
+ if (!isValidAddressForChain(address, {
18588
+ name: type,
18589
+ type
18590
+ })) {
18591
+ const { expectedAddressFormat } = extractChainInfo({
18592
+ name: type,
18593
+ type
18594
+ });
18595
+ ctx.addIssue({
18596
+ code: z.ZodIssueCode.custom,
18597
+ path: [
18598
+ type
18599
+ ],
18600
+ message: `Invalid ${type} address "${address}". Expected ${expectedAddressFormat}.`
18601
+ });
18602
+ }
18603
+ }
18604
+ });
18605
+ /**
18606
+ * Assert that the provided value conforms to {@link FeeRecipientsConfig}.
18607
+ *
18608
+ * Throws a validation error with annotated paths if the configuration is
18609
+ * malformed.
18610
+ *
18611
+ * @param config - The fee recipients map to validate.
18612
+ *
18613
+ * @example
18614
+ * ```ts
18615
+ * assertFeeRecipientsConfig({ evm: '0x1234567890123456789012345678901234567890' })
18616
+ * ```
18617
+ */ function assertFeeRecipientsConfig(config) {
18618
+ validateWithStateTracking(config, feeRecipientsConfigSchema, 'UnifiedBalanceKit fee recipients config', assertFeeRecipientsConfigSymbol);
18619
+ }
18620
+
18114
18621
  function sameChain(a, b) {
18115
18622
  return a.chain !== undefined && a.chain === b.chain;
18116
18623
  }
@@ -19091,6 +19598,105 @@ function assertSourceAccountAddresses(from) {
19091
19598
  config: params.config
19092
19599
  };
19093
19600
  }
19601
+ /**
19602
+ * Tracks, per {@link CustomFeePolicy} instance, which chain types have
19603
+ * already triggered the "falling back to resolveFeeRecipientAddress"
19604
+ * warning, so repeated `spend()`/`estimateSpend()` calls (e.g. live
19605
+ * quoting) warn once per (policy, chain type) pair rather than on every
19606
+ * call.
19607
+ */ const warnedFeeRecipientFallbacks = new WeakMap();
19608
+ /**
19609
+ * Invoke `resolveFeeRecipientAddress` and validate its return value has a
19610
+ * plausible address format for `destChain`, the same check
19611
+ * `setFeeRecipients()` already applies at config time. Unlike the map,
19612
+ * the callback's return value can't be validated ahead of time, so it's
19613
+ * checked here instead — a malformed value throws immediately rather
19614
+ * than silently becoming the fee recipient.
19615
+ *
19616
+ * @internal
19617
+ */ async function resolveFeeRecipientFromCallback(callback, destChain, params) {
19618
+ const address = await callback(destChain, params);
19619
+ if (!isValidAddressForChain(address, destChain)) {
19620
+ throw new KitError({
19621
+ ...InputError.VALIDATION_FAILED,
19622
+ recoverability: 'FATAL',
19623
+ message: `resolveFeeRecipientAddress returned an invalid address ` + `"${address}" for chain type "${destChain.type}" ` + `(resolved destination: ${destChain.name}).`
19624
+ });
19625
+ }
19626
+ return address;
19627
+ }
19628
+ /**
19629
+ * Resolve the single fee recipient address for a spend.
19630
+ *
19631
+ * Every fee burn intent in a spend mints to the same destination
19632
+ * chain regardless of which source chain(s) funded it, so exactly one
19633
+ * recipient address — valid on `destChain` — is ever needed.
19634
+ *
19635
+ * `feeRecipients` (set via `setFeeRecipients`) takes priority over the
19636
+ * policy's `resolveFeeRecipientAddress` callback for any chain type it
19637
+ * has an entry for, since it's a direct lookup and doesn't require
19638
+ * invoking developer code. For a chain type `feeRecipients` doesn't
19639
+ * cover, it falls back to `resolveFeeRecipientAddress` if one is
19640
+ * configured — a warning is logged once per (policy, chain type) pair
19641
+ * so the fallback isn't a silent surprise, without spamming repeated
19642
+ * `estimateSpend()` calls used for live quoting. Throws if neither
19643
+ * resolves `destChain`'s type, or if `resolveFeeRecipientAddress`
19644
+ * resolves it to a malformed address (see
19645
+ * {@link resolveFeeRecipientFromCallback}).
19646
+ *
19647
+ * @internal
19648
+ */ async function resolveFeeRecipient(destChain, policy, feeRecipients, params) {
19649
+ if (feeRecipients) {
19650
+ // `destChain.type` is `@core/chains`' broader `ChainType` union;
19651
+ // `FeeRecipientChainType` is the narrower subset this map supports
19652
+ // today. A type not present as a key simply has no configured
19653
+ // recipient, which is handled below.
19654
+ const type = destChain.type;
19655
+ const recipientAddress = feeRecipients[type];
19656
+ if (recipientAddress) {
19657
+ return recipientAddress;
19658
+ }
19659
+ if (policy.resolveFeeRecipientAddress) {
19660
+ const warnedTypes = warnedFeeRecipientFallbacks.get(policy);
19661
+ if (!warnedTypes?.has(type)) {
19662
+ warnedFeeRecipientFallbacks.set(policy, (warnedTypes ?? new Set()).add(type));
19663
+ console.warn(`setFeeRecipients() is configured but has no entry for chain ` + `type "${type}" — falling back to customFeePolicy.` + `resolveFeeRecipientAddress for this chain type. Add a ` + `"${type}" entry to setFeeRecipients() to avoid this fallback.`);
19664
+ }
19665
+ return resolveFeeRecipientFromCallback(policy.resolveFeeRecipientAddress, destChain, params);
19666
+ }
19667
+ throw new KitError({
19668
+ ...InputError.VALIDATION_FAILED,
19669
+ recoverability: 'FATAL',
19670
+ message: `No fee recipient configured for chain type "${type}" ` + `(resolved destination: ${destChain.name}). Call setFeeRecipients() ` + `with an entry for "${type}", or provide resolveFeeRecipientAddress ` + `on the custom fee policy.`
19671
+ });
19672
+ }
19673
+ if (!policy.resolveFeeRecipientAddress) {
19674
+ throw new KitError({
19675
+ ...InputError.VALIDATION_FAILED,
19676
+ recoverability: 'FATAL',
19677
+ message: 'No fee recipient configured — call setFeeRecipients() or provide ' + 'resolveFeeRecipientAddress on the custom fee policy.'
19678
+ });
19679
+ }
19680
+ return resolveFeeRecipientFromCallback(policy.resolveFeeRecipientAddress, destChain, params);
19681
+ }
19682
+ /**
19683
+ * Guard against a common misconfiguration: a developer sets the
19684
+ * declarative `feeRecipients` map expecting it alone to drive fee
19685
+ * collection, but no fee is ever charged without a `computeFee` from
19686
+ * `customFeePolicy` to determine the amount. Without this check that
19687
+ * misconfiguration fails silently — no fee is charged and no error is
19688
+ * raised.
19689
+ *
19690
+ * @internal
19691
+ */ function assertFeeRecipientsHasPolicy(feeRecipients) {
19692
+ if (feeRecipients) {
19693
+ throw new KitError({
19694
+ ...InputError.VALIDATION_FAILED,
19695
+ recoverability: 'FATAL',
19696
+ message: 'setFeeRecipients() is configured but no developer fee will be ' + 'charged: setCustomFeePolicy() must also be set to provide ' + 'computeFee, which determines the fee amount. Call ' + 'setCustomFeePolicy(), or remove setFeeRecipients() if no ' + 'developer fee is intended.'
19697
+ });
19698
+ }
19699
+ }
19094
19700
  /**
19095
19701
  * Apply a {@link CustomFeePolicy} to an adapter-only spend.
19096
19702
  *
@@ -19099,15 +19705,20 @@ function assertSourceAccountAddresses(from) {
19099
19705
  * `config.customFee` so the provider sees it.
19100
19706
  *
19101
19707
  * @internal
19102
- */ async function mergeCustomFeePolicyForAdapterOnly(params, policy) {
19103
- if (params.config?.customFee || !policy) {
19708
+ */ async function mergeCustomFeePolicyForAdapterOnly(params, policy, feeRecipients) {
19709
+ if (params.config?.customFee) {
19710
+ return params;
19711
+ }
19712
+ if (!policy) {
19713
+ assertFeeRecipientsHasPolicy(feeRecipients);
19104
19714
  return params;
19105
19715
  }
19106
19716
  const destChain = resolveChainIdentifier(params.to.chain);
19107
- const [feeValue, recipientAddress] = await Promise.all([
19108
- policy.computeFee(params),
19109
- policy.resolveFeeRecipientAddress(destChain, params)
19110
- ]);
19717
+ // Resolve the recipient before computing the fee: a KitError here
19718
+ // (missing/unresolvable recipient) shouldn't be preceded by an
19719
+ // otherwise-wasted computeFee call, which may be a network request.
19720
+ const recipientAddress = await resolveFeeRecipient(destChain, policy, feeRecipients, params);
19721
+ const feeValue = await policy.computeFee(params);
19111
19722
  return {
19112
19723
  ...params,
19113
19724
  config: {
@@ -19137,18 +19748,35 @@ function assertSourceAccountAddresses(from) {
19137
19748
  });
19138
19749
  }
19139
19750
  }
19140
- async function mergeCustomFeeConfig(resolved, policy) {
19141
- if (resolved.config?.customFee || !policy) {
19751
+ async function mergeCustomFeeConfig(resolved, policy, feeRecipients) {
19752
+ if (resolved.config?.customFee) {
19142
19753
  return resolved;
19143
19754
  }
19144
- const firstSourceChain = resolved.from[0]?.allocations[0]?.chain;
19145
- if (!firstSourceChain) {
19755
+ if (!policy) {
19756
+ assertFeeRecipientsHasPolicy(feeRecipients);
19146
19757
  return resolved;
19147
19758
  }
19148
- const [feeValue, recipientAddress] = await Promise.all([
19149
- policy.computeFee(resolved),
19150
- policy.resolveFeeRecipientAddress(firstSourceChain, resolved)
19151
- ]);
19759
+ // Skip fee resolution when there's no source chain to spend from at
19760
+ // all. This state can't arise from validated input today — the
19761
+ // caller re-checks and throws "No source chain found" right after
19762
+ // this returns — but skipping here isn't dead code: verified that
19763
+ // removing it lets a degenerate zero-allocation resolved value reach
19764
+ // computeFee/assertDeveloperFeeWithinBounds first, which throws a
19765
+ // misleading "Developer fee must be less than the total spend
19766
+ // amount" (0 >= 0 total allocation) instead of the correct "No
19767
+ // source chain found" error — or, for a real developer computeFee
19768
+ // that assumes a non-empty allocation, an uncaught raw exception
19769
+ // instead of any KitError at all. This guard exists to guarantee the
19770
+ // caller's clear error is what actually surfaces, not for
19771
+ // correctness.
19772
+ if (collectSourceChains(resolved).length === 0) {
19773
+ return resolved;
19774
+ }
19775
+ // Resolve the recipient before computing the fee: a KitError here
19776
+ // (missing/unresolvable recipient) shouldn't be preceded by an
19777
+ // otherwise-wasted computeFee call, which may be a network request.
19778
+ const recipientAddress = await resolveFeeRecipient(resolved.to.chain, policy, feeRecipients, resolved);
19779
+ const feeValue = await policy.computeFee(resolved);
19152
19780
  return {
19153
19781
  ...resolved,
19154
19782
  config: {
@@ -19204,14 +19832,14 @@ async function mergeCustomFeeConfig(resolved, policy) {
19204
19832
  }
19205
19833
  const destChain = resolveChainIdentifier(params.to.chain);
19206
19834
  if (!hasExplicitAllocations(params.from)) {
19207
- const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy);
19835
+ const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy, context.feeRecipients);
19208
19836
  assertDeveloperFeeWithinAmount(merged);
19209
19837
  const provider = findProviderForChain(context, normalizeToken(merged.token), destChain);
19210
19838
  return callSpend(provider, toProviderAdapterOnlyParams(merged));
19211
19839
  }
19212
19840
  const resolved = await resolveSpendParams(params);
19213
19841
  assertSpendNetworkCompatibility(resolved);
19214
- const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy);
19842
+ const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy, context.feeRecipients);
19215
19843
  assertDeveloperFeeWithinBounds(withFee);
19216
19844
  const sourceChains = collectSourceChains(withFee);
19217
19845
  if (sourceChains.length === 0) {
@@ -19251,14 +19879,14 @@ async function mergeCustomFeeConfig(resolved, policy) {
19251
19879
  assertSpendParams(params);
19252
19880
  const destChain = resolveChainIdentifier(params.to.chain);
19253
19881
  if (!hasExplicitAllocations(params.from)) {
19254
- const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy);
19882
+ const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy, context.feeRecipients);
19255
19883
  assertDeveloperFeeWithinAmount(merged);
19256
19884
  const provider = findProviderForChain(context, normalizeToken(merged.token), destChain);
19257
19885
  return provider.estimateSpend(toProviderAdapterOnlyParams(merged));
19258
19886
  }
19259
19887
  const resolved = await resolveSpendParams(params);
19260
19888
  assertSpendNetworkCompatibility(resolved);
19261
- const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy);
19889
+ const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy, context.feeRecipients);
19262
19890
  assertDeveloperFeeWithinBounds(withFee);
19263
19891
  const sourceChains = collectSourceChains(withFee);
19264
19892
  if (sourceChains.length === 0) {
@@ -20233,6 +20861,46 @@ const removeFundParamsSchema = z.object({
20233
20861
  */ removeCustomFeePolicy() {
20234
20862
  delete this.context.customFeePolicy;
20235
20863
  }
20864
+ /**
20865
+ * Set a declarative fee recipient map, keyed by chain type. Once set,
20866
+ * `spend()`/`estimateSpend()` resolve the fee recipient by looking up
20867
+ * the spend's destination chain type in this map — taking priority
20868
+ * over `customFeePolicy`'s `resolveFeeRecipientAddress` callback.
20869
+ *
20870
+ * @remarks
20871
+ * This only controls which address a fee is sent to — it does not by
20872
+ * itself cause any fee to be charged. You still need
20873
+ * {@link UnifiedBalanceKit.setCustomFeePolicy}'s `computeFee` to
20874
+ * determine the fee amount; calling `setFeeRecipients` without ever
20875
+ * calling `setCustomFeePolicy` throws at spend time (there is no
20876
+ * `computeFee` to determine an amount).
20877
+ *
20878
+ * @param config - Fee recipient addresses keyed by chain type (e.g.
20879
+ * `{ evm: '0x...', solana: 'Sol...' }`). Provide entries for every
20880
+ * chain type you expect to spend to; spending to a chain type with
20881
+ * no matching entry throws before any fee collection is attempted.
20882
+ *
20883
+ * @example
20884
+ * ```typescript
20885
+ * kit.setFeeRecipients({
20886
+ * evm: '0x1234567890123456789012345678901234567890',
20887
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
20888
+ * })
20889
+ * ```
20890
+ */ setFeeRecipients(config) {
20891
+ assertFeeRecipientsConfig(config);
20892
+ this.context.feeRecipients = config;
20893
+ }
20894
+ /**
20895
+ * Remove the declarative fee recipient map for the kit.
20896
+ *
20897
+ * @example
20898
+ * ```typescript
20899
+ * kit.removeFeeRecipients()
20900
+ * ```
20901
+ */ removeFeeRecipients() {
20902
+ delete this.context.feeRecipients;
20903
+ }
20236
20904
  }
20237
20905
 
20238
20906
  // Auto-register this kit for user agent tracking
@@ -20559,6 +21227,45 @@ registerKit(`${pkg.name}/${pkg.version}`);
20559
21227
  */ removeCustomFeePolicy() {
20560
21228
  this.kit.removeCustomFeePolicy();
20561
21229
  }
21230
+ /**
21231
+ * Set a declarative fee recipient map, keyed by chain type.
21232
+ *
21233
+ * Once set, `spend()`/`estimateSpend()` resolve the fee recipient by
21234
+ * looking up the spend's destination chain type in this map — taking
21235
+ * priority over `customFeePolicy`'s `resolveFeeRecipientAddress`
21236
+ * callback.
21237
+ *
21238
+ * @remarks
21239
+ * This only controls which address a fee is sent to — it does not by
21240
+ * itself cause any fee to be charged. You still need
21241
+ * `setCustomFeePolicy`'s `computeFee` to determine the fee amount;
21242
+ * calling `setFeeRecipients` without ever calling `setCustomFeePolicy`
21243
+ * throws at spend time (there is no `computeFee` to determine an
21244
+ * amount).
21245
+ *
21246
+ * @param config - Fee recipient addresses keyed by chain type (e.g.
21247
+ * `{ evm: '0x...', solana: 'Sol...' }`).
21248
+ *
21249
+ * @example
21250
+ * ```typescript
21251
+ * kit.unifiedBalance.setFeeRecipients({
21252
+ * evm: '0x1234567890123456789012345678901234567890',
21253
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
21254
+ * })
21255
+ * ```
21256
+ */ setFeeRecipients(config) {
21257
+ this.kit.setFeeRecipients(config);
21258
+ }
21259
+ /**
21260
+ * Remove the declarative fee recipient map.
21261
+ *
21262
+ * @example
21263
+ * ```typescript
21264
+ * kit.unifiedBalance.removeFeeRecipients()
21265
+ * ```
21266
+ */ removeFeeRecipients() {
21267
+ this.kit.removeFeeRecipients();
21268
+ }
20562
21269
  }
20563
21270
 
20564
21271
  export { AppKitUnifiedBalance };