@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
@@ -2243,6 +2243,8 @@ class KitError extends Error {
2243
2243
  Blockchain["Celo_Alfajores_Testnet"] = "Celo_Alfajores_Testnet";
2244
2244
  Blockchain["Codex"] = "Codex";
2245
2245
  Blockchain["Codex_Testnet"] = "Codex_Testnet";
2246
+ Blockchain["Cronos"] = "Cronos";
2247
+ Blockchain["Cronos_Testnet"] = "Cronos_Testnet";
2246
2248
  Blockchain["Edge"] = "Edge";
2247
2249
  Blockchain["Edge_Testnet"] = "Edge_Testnet";
2248
2250
  Blockchain["Ethereum"] = "Ethereum";
@@ -2325,6 +2327,7 @@ var BridgeChain;
2325
2327
  BridgeChain["Avalanche"] = "Avalanche";
2326
2328
  BridgeChain["Base"] = "Base";
2327
2329
  BridgeChain["Codex"] = "Codex";
2330
+ BridgeChain["Cronos"] = "Cronos";
2328
2331
  BridgeChain["Edge"] = "Edge";
2329
2332
  BridgeChain["Ethereum"] = "Ethereum";
2330
2333
  BridgeChain["HyperEVM"] = "HyperEVM";
@@ -2349,6 +2352,7 @@ var BridgeChain;
2349
2352
  BridgeChain["Avalanche_Fuji"] = "Avalanche_Fuji";
2350
2353
  BridgeChain["Base_Sepolia"] = "Base_Sepolia";
2351
2354
  BridgeChain["Codex_Testnet"] = "Codex_Testnet";
2355
+ BridgeChain["Cronos_Testnet"] = "Cronos_Testnet";
2352
2356
  BridgeChain["Edge_Testnet"] = "Edge_Testnet";
2353
2357
  BridgeChain["Ethereum_Sepolia"] = "Ethereum_Sepolia";
2354
2358
  BridgeChain["HyperEVM_Testnet"] = "HyperEVM_Testnet";
@@ -2870,7 +2874,10 @@ var EarnChain;
2870
2874
  contracts: {
2871
2875
  v1: {
2872
2876
  wallet: GATEWAY_WALLET_EVM_TESTNET,
2873
- minter: GATEWAY_MINTER_EVM_TESTNET
2877
+ minter: GATEWAY_MINTER_EVM_TESTNET,
2878
+ // DepositForHandler the GenericExecutor calls to run a fast cross-chain
2879
+ // deposit into the GatewayWallet above.
2880
+ depositForHandler: '0xD05E7D2E7d30b92c5F17d7d0fC575fce231F1A48'
2874
2881
  }
2875
2882
  },
2876
2883
  forwarderSupported: {
@@ -3404,6 +3411,96 @@ var EarnChain;
3404
3411
  }
3405
3412
  });
3406
3413
 
3414
+ /**
3415
+ * Cronos Mainnet chain definition
3416
+ * @remarks
3417
+ * This represents the official production network for the Cronos blockchain.
3418
+ * Cronos is an EVM-compatible blockchain.
3419
+ */ const Cronos = defineChain({
3420
+ type: 'evm',
3421
+ chain: Blockchain.Cronos,
3422
+ name: 'Cronos',
3423
+ title: 'Cronos Mainnet',
3424
+ nativeCurrency: {
3425
+ name: 'Cronos',
3426
+ symbol: 'CRO',
3427
+ decimals: 18
3428
+ },
3429
+ chainId: 25,
3430
+ isTestnet: false,
3431
+ explorerUrl: 'https://cronoscan.com/tx/{hash}',
3432
+ rpcEndpoints: [
3433
+ 'https://evm.cronos.org'
3434
+ ],
3435
+ eurcAddress: '0xA6dE01a2d62C6B5f3525d768f34d276652C554c8',
3436
+ usdcAddress: '0x3D7F2C478aAfdB65542BCB44bCeeC05849999d2D',
3437
+ usdtAddress: null,
3438
+ cctp: {
3439
+ domain: 32,
3440
+ contracts: {
3441
+ v2: {
3442
+ type: 'split',
3443
+ tokenMessenger: '0x28b5a0e9C621a5BadaA536219b3a228C8168cf5d',
3444
+ messageTransmitter: '0x81D40F21F12A8F0E3252Bccb954D722d4c464B64',
3445
+ confirmations: 1,
3446
+ fastConfirmations: 1
3447
+ }
3448
+ },
3449
+ forwarderSupported: {
3450
+ source: false,
3451
+ destination: false
3452
+ }
3453
+ },
3454
+ kitContracts: {
3455
+ bridge: BRIDGE_CONTRACT_EVM_MAINNET
3456
+ }
3457
+ });
3458
+
3459
+ /**
3460
+ * Cronos Testnet chain definition
3461
+ * @remarks
3462
+ * This represents the official test network for the Cronos blockchain.
3463
+ * Cronos is an EVM-compatible blockchain.
3464
+ */ const CronosTestnet = defineChain({
3465
+ type: 'evm',
3466
+ chain: Blockchain.Cronos_Testnet,
3467
+ name: 'Cronos Testnet',
3468
+ title: 'Cronos Testnet',
3469
+ nativeCurrency: {
3470
+ name: 'CRO',
3471
+ symbol: 'tCRO',
3472
+ decimals: 18
3473
+ },
3474
+ chainId: 338,
3475
+ isTestnet: true,
3476
+ explorerUrl: 'https://explorer.cronos.org/testnet/tx/{hash}',
3477
+ rpcEndpoints: [
3478
+ 'https://evm-t3.cronos.org'
3479
+ ],
3480
+ eurcAddress: '0x31f7538adb53cF16350e6B0c89d03D91b7D12c46',
3481
+ usdcAddress: '0xEb33dc5fac03833e132593659e1dE7256aB59794',
3482
+ usdtAddress: null,
3483
+ cctp: {
3484
+ domain: 32,
3485
+ contracts: {
3486
+ v2: {
3487
+ type: 'split',
3488
+ tokenMessenger: '0x8FE6B999Dc680CcFDD5Bf7EB0974218be2542DAA',
3489
+ messageTransmitter: '0xE737e5cEBEEBa77EFE34D4aa090756590b1CE275',
3490
+ confirmations: 1,
3491
+ fastConfirmations: 1
3492
+ }
3493
+ },
3494
+ forwarderSupported: {
3495
+ source: false,
3496
+ destination: false
3497
+ }
3498
+ },
3499
+ kitContracts: {
3500
+ bridge: BRIDGE_CONTRACT_EVM_TESTNET
3501
+ }
3502
+ });
3503
+
3407
3504
  /**
3408
3505
  * Edge Mainnet chain definition
3409
3506
  * @remarks
@@ -5755,6 +5852,8 @@ var Chains = {
5755
5852
  CeloAlfajoresTestnet: CeloAlfajoresTestnet,
5756
5853
  Codex: Codex,
5757
5854
  CodexTestnet: CodexTestnet,
5855
+ Cronos: Cronos,
5856
+ CronosTestnet: CronosTestnet,
5758
5857
  Edge: Edge,
5759
5858
  EdgeTestnet: EdgeTestnet,
5760
5859
  Ethereum: Ethereum,
@@ -5862,7 +5961,10 @@ var Chains = {
5862
5961
  minter: zod.z.string({
5863
5962
  required_error: 'Gateway minter address is required. Please provide a valid contract address.',
5864
5963
  invalid_type_error: 'Gateway minter address must be a string.'
5865
- }).min(1, 'Gateway minter address cannot be empty.')
5964
+ }).min(1, 'Gateway minter address cannot be empty.'),
5965
+ depositForHandler: zod.z.string({
5966
+ invalid_type_error: 'Gateway depositForHandler address must be a string.'
5967
+ }).min(1, 'Gateway depositForHandler address cannot be empty.').optional()
5866
5968
  }).strict() // Reject any additional properties not defined in the schema
5867
5969
  ;
5868
5970
  /**
@@ -7728,6 +7830,7 @@ function parseOrThrow(value, schema, context) {
7728
7830
  [Blockchain.Base]: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
7729
7831
  [Blockchain.Celo]: '0xcebA9300f2b948710d2653dD7B07f33A8B32118C',
7730
7832
  [Blockchain.Codex]: '0xd996633a415985DBd7D6D12f4A4343E31f5037cf',
7833
+ [Blockchain.Cronos]: '0x3D7F2C478aAfdB65542BCB44bCeeC05849999d2D',
7731
7834
  [Blockchain.Edge]: '0x98d2919b9A214E6Fa5384AC81E6864bA686Ad74c',
7732
7835
  [Blockchain.Ethereum]: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
7733
7836
  [Blockchain.Hedera]: '0.0.456858',
@@ -7761,6 +7864,7 @@ function parseOrThrow(value, schema, context) {
7761
7864
  [Blockchain.Avalanche_Fuji]: '0x5425890298aed601595a70AB815c96711a31Bc65',
7762
7865
  [Blockchain.Base_Sepolia]: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
7763
7866
  [Blockchain.Codex_Testnet]: '0x6d7f141b6819C2c9CC2f818e6ad549E7Ca090F8f',
7867
+ [Blockchain.Cronos_Testnet]: '0xEb33dc5fac03833e132593659e1dE7256aB59794',
7764
7868
  [Blockchain.Edge_Testnet]: '0x2d9F7CAD728051AA35Ecdc472a14cf8cDF5CFD6B',
7765
7869
  [Blockchain.Ethereum_Sepolia]: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238',
7766
7870
  [Blockchain.Hedera_Testnet]: '0.0.429274',
@@ -7833,6 +7937,7 @@ function parseOrThrow(value, schema, context) {
7833
7937
  // =========================================================================
7834
7938
  [Blockchain.Avalanche]: '0xc891EB4cbdEFf6e073e859e987815Ed1505c2ACD',
7835
7939
  [Blockchain.Base]: '0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42',
7940
+ [Blockchain.Cronos]: '0xA6dE01a2d62C6B5f3525d768f34d276652C554c8',
7836
7941
  [Blockchain.Ethereum]: '0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c',
7837
7942
  [Blockchain.Solana]: 'HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr',
7838
7943
  [Blockchain.World_Chain]: '0x1C60ba0A0eD1019e8Eb035E6daF4155A5cE2380B',
@@ -7841,6 +7946,7 @@ function parseOrThrow(value, schema, context) {
7841
7946
  // =========================================================================
7842
7947
  [Blockchain.Arc_Testnet]: '0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a',
7843
7948
  [Blockchain.Base_Sepolia]: '0x808456652fdb597867f38412077A9182bf77359F',
7949
+ [Blockchain.Cronos_Testnet]: '0x31f7538adb53cF16350e6B0c89d03D91b7D12c46',
7844
7950
  [Blockchain.Ethereum_Sepolia]: '0x08210F9170F89Ab7658F0B5E3fF39b0E03C594D4'
7845
7951
  }
7846
7952
  };
@@ -8645,7 +8751,7 @@ function parseOrThrow(value, schema, context) {
8645
8751
  }
8646
8752
 
8647
8753
  var name = "@circle-fin/unified-balance-kit";
8648
- var version = "1.2.1";
8754
+ var version = "1.3.0";
8649
8755
  var pkg = {
8650
8756
  name: name,
8651
8757
  version: version};
@@ -16281,29 +16387,33 @@ const CIRCLE_BPS_DIVISOR = 10_000n;
16281
16387
  const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16282
16388
  /**
16283
16389
  * Return the estimated Gateway gas fee for a chain in USDC atomic units.
16284
- * Falls back to a conservative 0.1 USDC for unlisted chains.
16390
+ * Prefers an entry in `overrides` (the real per-chain fee derived from a
16391
+ * prior estimate), then the static {@link GAS_FEE_BY_CHAIN} constant, and
16392
+ * finally a conservative 0.1 USDC fallback for unlisted chains.
16285
16393
  *
16286
16394
  * @param chain - The source blockchain.
16395
+ * @param overrides - Optional real per-chain gas fees keyed by chain.
16287
16396
  * @returns Gas fee in USDC atomic units.
16288
- */ function getGasFee(chain) {
16289
- return GAS_FEE_BY_CHAIN.get(chain) ?? DEFAULT_GAS_FEE;
16397
+ */ function getGasFee(chain, overrides) {
16398
+ return overrides?.get(chain) ?? GAS_FEE_BY_CHAIN.get(chain) ?? DEFAULT_GAS_FEE;
16290
16399
  }
16291
16400
  /**
16292
16401
  * Return the estimated forwarder fee for the destination chain
16293
16402
  * (service fee + destination gas fee).
16294
16403
  *
16295
16404
  * @param destinationChain - The mint destination chain.
16405
+ * @param overrides - Optional real per-chain gas fees keyed by chain.
16296
16406
  * @returns Forwarder fee in USDC atomic units.
16297
- */ function getForwarderFee(destinationChain) {
16298
- const destGas = getGasFee(destinationChain);
16407
+ */ function getForwarderFee(destinationChain, overrides) {
16408
+ const destGas = getGasFee(destinationChain, overrides);
16299
16409
  return FORWARDER_SERVICE_FEE + destGas;
16300
16410
  }
16301
16411
  /**
16302
16412
  * Estimate the fixed fees (gas + forwarder) and compute the maximum
16303
16413
  * amount that can be drawn from this chain for a single intent,
16304
16414
  * accounting for the 0.5 bps transfer fee if cross-chain.
16305
- */ function computeMaxDrawable(slot, forwarderFeeRemaining) {
16306
- const gasFee = getGasFee(slot.chain);
16415
+ */ function computeMaxDrawable(slot, forwarderFeeRemaining, overrides) {
16416
+ const gasFee = getGasFee(slot.chain, overrides);
16307
16417
  let fixedFees = gasFee;
16308
16418
  let forwarderFeeUsed = 0n;
16309
16419
  if (forwarderFeeRemaining > 0n) {
@@ -16335,14 +16445,14 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16335
16445
  * buffer), and returns the allocations for this pass.
16336
16446
  *
16337
16447
  * Mutates `slot.remaining` so the next pass sees reduced balances.
16338
- */ function greedyAllocate(slots, amount, destinationChain, useForwarder) {
16448
+ */ function greedyAllocate(slots, amount, destinationChain, useForwarder, overrides) {
16339
16449
  const result = [];
16340
16450
  let remaining = amount;
16341
- let forwarderFeeRemaining = useForwarder ? getForwarderFee(destinationChain) : 0n;
16451
+ let forwarderFeeRemaining = useForwarder ? getForwarderFee(destinationChain, overrides) : 0n;
16342
16452
  if (remaining <= 0n) return result;
16343
16453
  for (const slot of slots){
16344
16454
  if (remaining <= 0n) break;
16345
- const { drawable, gasFee, forwarderFeeUsed } = computeMaxDrawable(slot, forwarderFeeRemaining);
16455
+ const { drawable, gasFee, forwarderFeeUsed } = computeMaxDrawable(slot, forwarderFeeRemaining, overrides);
16346
16456
  if (drawable <= 0n) continue;
16347
16457
  // Greedy: take as much as we can from this chain
16348
16458
  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.
@@ -16441,7 +16551,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16441
16551
  // After this pass, slot.remaining reflects consumed capacity.
16442
16552
  // -----------------------------------------------------------------------
16443
16553
  const transferAmount = parseUnits(ctx.amountIn, USDC_DECIMALS);
16444
- const allocations = greedyAllocate(slots, transferAmount, ctx.destinationChain, ctx.useForwarder);
16554
+ const allocations = greedyAllocate(slots, transferAmount, ctx.destinationChain, ctx.useForwarder, ctx.gasFeeOverrides);
16445
16555
  assertFullyAllocated(allocations, transferAmount, ctx.amountIn);
16446
16556
  // -----------------------------------------------------------------------
16447
16557
  // 4. Phase 2 — Allocate developer fee
@@ -16449,7 +16559,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16449
16559
  // Same-chain first here too — if the destination chain still has
16450
16560
  // capacity, use it (same-chain fee intent = cheapest gas).
16451
16561
  // -----------------------------------------------------------------------
16452
- const developerFeeAllocations = greedyAllocate(slots, devFeeAmount, ctx.destinationChain, false);
16562
+ const developerFeeAllocations = greedyAllocate(slots, devFeeAmount, ctx.destinationChain, false, ctx.gasFeeOverrides);
16453
16563
  if (devFeeAmount > 0n) {
16454
16564
  assertFullyAllocated(developerFeeAllocations, devFeeAmount, formatUnits(devFeeAmount.toString(), USDC_DECIMALS));
16455
16565
  }
@@ -16458,7 +16568,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16458
16568
  // Again same ordering, same shared reduced balances.
16459
16569
  // Same-chain first for the same reason.
16460
16570
  // -----------------------------------------------------------------------
16461
- const circleFeeAllocations = greedyAllocate(slots, circleFeeAmount, ctx.destinationChain, false);
16571
+ const circleFeeAllocations = greedyAllocate(slots, circleFeeAmount, ctx.destinationChain, false, ctx.gasFeeOverrides);
16462
16572
  if (circleFeeAmount > 0n) {
16463
16573
  assertFullyAllocated(circleFeeAllocations, circleFeeAmount, formatUnits(circleFeeAmount.toString(), USDC_DECIMALS));
16464
16574
  }
@@ -16557,10 +16667,12 @@ const BPS_DIVISOR = 100_000n;
16557
16667
  *
16558
16668
  * Unlike `findChainNameByDomain` (which returns the display `name`),
16559
16669
  * this returns `chain.chain` — the enum identifier expected by
16560
- * {@link FeeAllocation}.
16670
+ * {@link FeeAllocation}. Returns `undefined` when no allocation covers the
16671
+ * domain so callers skip the intent rather than bucketing it under a
16672
+ * fabricated sentinel.
16561
16673
  */ function findBlockchainByDomain(domain, allocations) {
16562
16674
  const alloc = allocations.find((a)=>a.chain.gateway.domain === domain);
16563
- return alloc?.chain.chain ?? 'Unknown';
16675
+ return alloc?.chain.chain;
16564
16676
  }
16565
16677
  /**
16566
16678
  * Normalize any address/salt format to lowercase bytes32 hex.
@@ -16684,6 +16796,33 @@ const BPS_DIVISOR = 100_000n;
16684
16796
  };
16685
16797
  });
16686
16798
  }
16799
+ /**
16800
+ * Read an intent's transfer value as a BigInt, tolerating the string form
16801
+ * that can appear on estimate-response specs.
16802
+ */ function intentValue(intent) {
16803
+ const { value } = intent.spec;
16804
+ return typeof value === 'bigint' ? value : safeBigInt(String(value), 'spec.value');
16805
+ }
16806
+ /**
16807
+ * Split a single intent's `maxFee` into its transfer-fee and gas-fee
16808
+ * components.
16809
+ *
16810
+ * `transferFee = value * GATEWAY_TRANSFER_FEE_SCALED_BPS / BPS_DIVISOR`
16811
+ * `gasFee = maxFee - transferFee`
16812
+ *
16813
+ * Same-chain transfers (withdrawals) do not incur a transfer fee, so the
16814
+ * whole `maxFee` is gas. See {@link aggregateFeesByIntent} for the caveats
16815
+ * on re-deriving the split locally.
16816
+ */ function splitIntentFee(intent) {
16817
+ const { maxFee, spec } = intent;
16818
+ const isSameChain = spec.sourceDomain === spec.destinationDomain;
16819
+ const transferFee = isSameChain ? 0n : intentValue(intent) * GATEWAY_TRANSFER_FEE_SCALED_BPS / BPS_DIVISOR;
16820
+ const gasFee = maxFee > transferFee ? maxFee - transferFee : 0n;
16821
+ return {
16822
+ transferFee,
16823
+ gasFee
16824
+ };
16825
+ }
16687
16826
  /**
16688
16827
  * Decompose each intent's `maxFee` into a transfer fee and a gas fee,
16689
16828
  * then aggregate both by source chain.
@@ -16708,7 +16847,6 @@ const BPS_DIVISOR = 100_000n;
16708
16847
  * names from source domains.
16709
16848
  * @returns Per-chain and total transfer/gas fee breakdowns.
16710
16849
  */ function aggregateFeesByIntent(estimatedIntents, allocations) {
16711
- const transferFeeBps = GATEWAY_TRANSFER_FEE_SCALED_BPS;
16712
16850
  const transferFeeByChain = new Map();
16713
16851
  const gasFeeByChain = new Map();
16714
16852
  let totalTransferFee = 0n;
@@ -16716,18 +16854,18 @@ const BPS_DIVISOR = 100_000n;
16716
16854
  for (const intent of estimatedIntents){
16717
16855
  const { maxFee, spec } = intent;
16718
16856
  if (maxFee === 0n) continue;
16719
- const chainName = findBlockchainByDomain(spec.sourceDomain, allocations);
16720
- const value = typeof spec.value === 'bigint' ? spec.value : safeBigInt(String(spec.value), 'spec.value');
16721
- const isSameChain = spec.sourceDomain === spec.destinationDomain;
16722
- const transferFee = isSameChain ? 0n : value * transferFeeBps / BPS_DIVISOR;
16723
- const gasFee = maxFee > transferFee ? maxFee - transferFee : 0n;
16857
+ const { transferFee, gasFee } = splitIntentFee(intent);
16858
+ totalTransferFee += transferFee;
16859
+ totalGasFee += gasFee;
16860
+ // Totals stay complete even if a domain can't be resolved; only the
16861
+ // per-chain breakdown skips it rather than inventing a placeholder chain.
16862
+ const chain = findBlockchainByDomain(spec.sourceDomain, allocations);
16863
+ if (chain === undefined) continue;
16724
16864
  if (transferFee > 0n) {
16725
- totalTransferFee += transferFee;
16726
- transferFeeByChain.set(chainName, (transferFeeByChain.get(chainName) ?? 0n) + transferFee);
16865
+ transferFeeByChain.set(chain, (transferFeeByChain.get(chain) ?? 0n) + transferFee);
16727
16866
  }
16728
16867
  if (gasFee > 0n) {
16729
- totalGasFee += gasFee;
16730
- gasFeeByChain.set(chainName, (gasFeeByChain.get(chainName) ?? 0n) + gasFee);
16868
+ gasFeeByChain.set(chain, (gasFeeByChain.get(chain) ?? 0n) + gasFee);
16731
16869
  }
16732
16870
  }
16733
16871
  return {
@@ -16791,6 +16929,91 @@ const BPS_DIVISOR = 100_000n;
16791
16929
  }
16792
16930
  return fees;
16793
16931
  }
16932
+ /**
16933
+ * Derive the real per-chain Gateway gas fee from estimated intents, keyed by
16934
+ * source {@link Blockchain}.
16935
+ *
16936
+ * The value is the maximum single-intent gas fee observed on each chain
16937
+ * (`maxFee − transferFee`) — the amount `computeAutoAllocation` must reserve
16938
+ * per burn intent on that chain. Gas is (near) amount-independent, so every
16939
+ * intent on a chain pays roughly the same; taking the max is a conservative
16940
+ * choice for the multi-intent-per-chain case.
16941
+ *
16942
+ * Intended for `AutoAllocationContext.gasFeeOverrides` so the corrective
16943
+ * re-allocation pass reserves the API's real fee instead of the static
16944
+ * {@link GAS_FEE_BY_CHAIN} constant.
16945
+ *
16946
+ * @param estimatedIntents - Intents with `maxFee` from {@link parseEstimateResponse}.
16947
+ * @param allocations - Normalised allocations used to resolve chain from source domain.
16948
+ * @returns Per-chain real gas fees in USDC atomic units.
16949
+ *
16950
+ * @example
16951
+ * ```typescript
16952
+ * import type { BurnIntent } from '../createIntent/types'
16953
+ * import type { NormalizedAllocation } from '../allocations'
16954
+ *
16955
+ * declare const estimatedIntents: BurnIntent[]
16956
+ * declare const allocations: NormalizedAllocation[]
16957
+ *
16958
+ * // Real per-chain gas, ready to pass as AutoAllocationContext.gasFeeOverrides
16959
+ * // to re-run computeAutoAllocation with the corrected reserve.
16960
+ * const overrides = deriveGasFeeOverrides(estimatedIntents, allocations)
16961
+ * ```
16962
+ */ function deriveGasFeeOverrides(estimatedIntents, allocations) {
16963
+ const overrides = new Map();
16964
+ for (const intent of estimatedIntents){
16965
+ if (intent.maxFee === 0n) continue;
16966
+ const chain = findBlockchainByDomain(intent.spec.sourceDomain, allocations);
16967
+ if (chain === undefined) continue;
16968
+ const { gasFee } = splitIntentFee(intent);
16969
+ const prev = overrides.get(chain) ?? 0n;
16970
+ if (gasFee > prev) overrides.set(chain, gasFee);
16971
+ }
16972
+ return overrides;
16973
+ }
16974
+ /**
16975
+ * Sum the total balance each source chain must cover, keyed by source
16976
+ * {@link Blockchain}.
16977
+ *
16978
+ * Approximates the Gateway API's balance validation, which rejects a transfer
16979
+ * (`BALANCE_INSUFFICIENT_TOKEN`) when a depositor's confirmed balance on a
16980
+ * source chain is below `sum(intent.value + intent.maxFee)` for that
16981
+ * depositor's intents. This aggregates by chain across all sources, so it is
16982
+ * exact for the common single-depositor-per-chain wallet. When several
16983
+ * depositors hold USDC on the same chain, the chain-level sum can mask a
16984
+ * per-depositor shortfall (or a surplus on one depositor can hide it); the
16985
+ * API's own per-depositor `9001` remains the backstop for that case. Scope the
16986
+ * comparison per (depositor, chain) if that multi-depositor case must be caught
16987
+ * pre-submit.
16988
+ *
16989
+ * @param estimatedIntents - Intents with `maxFee` from {@link parseEstimateResponse}.
16990
+ * @param allocations - Normalised allocations used to resolve chain from source domain.
16991
+ * @returns Per-chain required amount (transfer value + fees) in USDC atomic units.
16992
+ *
16993
+ * @example
16994
+ * ```typescript
16995
+ * import { Blockchain } from '@core/chains'
16996
+ * import type { BurnIntent } from '../createIntent/types'
16997
+ * import type { NormalizedAllocation } from '../allocations'
16998
+ *
16999
+ * declare const estimatedIntents: BurnIntent[]
17000
+ * declare const allocations: NormalizedAllocation[]
17001
+ * declare const confirmedBalanceAtomic: bigint
17002
+ *
17003
+ * const required = sumRequiredPerChain(estimatedIntents, allocations)
17004
+ * const overDrawn =
17005
+ * (required.get(Blockchain.Ethereum) ?? 0n) > confirmedBalanceAtomic
17006
+ * ```
17007
+ */ function sumRequiredPerChain(estimatedIntents, allocations) {
17008
+ const required = new Map();
17009
+ for (const intent of estimatedIntents){
17010
+ const chain = findBlockchainByDomain(intent.spec.sourceDomain, allocations);
17011
+ if (chain === undefined) continue;
17012
+ const amount = intentValue(intent) + intent.maxFee;
17013
+ required.set(chain, (required.get(chain) ?? 0n) + amount);
17014
+ }
17015
+ return required;
17016
+ }
16794
17017
 
16795
17018
  /**
16796
17019
  * Sign each adapter group: Solana one intent per signature, EVM batch per adapter.
@@ -17045,69 +17268,127 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17045
17268
  * non-forwarder transfer response is missing attestation or signature.
17046
17269
  * @throws KitError Propagated from adapter signing if the user rejects
17047
17270
  * or the signer is unavailable.
17048
- */ async function resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder) {
17049
- if (params.amountIn) {
17050
- const rawSources = Array.isArray(params.from) ? params.from : [
17051
- params.from
17052
- ];
17053
- const sourcesArray = rawSources.filter((s)=>s != null);
17054
- const networkType = destChain.isTestnet ? 'testnet' : 'mainnet';
17055
- const balanceResults = await Promise.all(sourcesArray.map(async (source)=>{
17056
- // When sourceAccount is set (delegate flow), scope the balance
17057
- // query to the Gateway depositor not the signer. Using the
17058
- // address-only path bypasses adapter address resolution, which
17059
- // would otherwise return the signer's balance (developer-
17060
- // controlled) or reject an explicit address (user-controlled).
17061
- let querySource;
17062
- if (source.sourceAccount) {
17063
- querySource = {
17064
- address: source.sourceAccount
17065
- };
17066
- } else {
17067
- querySource = {
17068
- adapter: source.adapter
17069
- };
17070
- if ('address' in source && source.address) {
17071
- querySource['address'] = source.address;
17072
- }
17073
- }
17074
- return getBalances$1({
17075
- token: params.token,
17076
- sources: querySource,
17077
- networkType
17078
- });
17079
- }));
17080
- const chainBalances = [];
17081
- for(let i = 0; i < balanceResults.length; i++){
17082
- const breakdowns = balanceResults[i]?.breakdown[0]?.breakdown ?? [];
17083
- for (const b of breakdowns){
17084
- chainBalances.push({
17085
- chain: b.chain,
17086
- confirmedBalance: b.confirmedBalance,
17087
- sourceIndex: i
17088
- });
17271
+ */ /**
17272
+ * Fetch confirmed per-chain USDC balances for every auto-allocation source.
17273
+ *
17274
+ * Used only on the `amountIn` (auto-allocation) path. Returns one
17275
+ * {@link ChainBalance} per (source, chain) pair so the greedy allocator — and
17276
+ * the corrective re-allocation pass — can reason about draw limits without a
17277
+ * second balance round-trip.
17278
+ *
17279
+ * @param params - Spend parameters (source(s) and token).
17280
+ * @param destChain - Resolved destination chain (used for network type).
17281
+ * @returns Confirmed balances tagged with their originating source index.
17282
+ */ async function fetchChainBalances(params, destChain) {
17283
+ const rawSources = Array.isArray(params.from) ? params.from : [
17284
+ params.from
17285
+ ];
17286
+ const sourcesArray = rawSources.filter((s)=>s != null);
17287
+ const networkType = destChain.isTestnet ? 'testnet' : 'mainnet';
17288
+ const balanceResults = await Promise.all(sourcesArray.map(async (source)=>{
17289
+ // When sourceAccount is set (delegate flow), scope the balance
17290
+ // query to the Gateway depositor — not the signer. Using the
17291
+ // address-only path bypasses adapter address resolution, which
17292
+ // would otherwise return the signer's balance (developer-
17293
+ // controlled) or reject an explicit address (user-controlled).
17294
+ let querySource;
17295
+ if (source.sourceAccount) {
17296
+ querySource = {
17297
+ address: source.sourceAccount
17298
+ };
17299
+ } else {
17300
+ querySource = {
17301
+ adapter: source.adapter
17302
+ };
17303
+ if ('address' in source && source.address) {
17304
+ querySource['address'] = source.address;
17089
17305
  }
17090
17306
  }
17091
- const customFeeConfig = params.config?.customFee;
17092
- const autoAllocResult = computeAutoAllocation({
17093
- amountIn: params.amountIn,
17094
- destinationChain: destChain.chain,
17095
- chainBalances,
17096
- useForwarder,
17097
- ...customFeeConfig ? {
17098
- customFee: customFeeConfig
17099
- } : {}
17307
+ return getBalances$1({
17308
+ token: params.token,
17309
+ sources: querySource,
17310
+ networkType
17100
17311
  });
17101
- const normalizedAutoAllocations = await normalizeAutoAllocations(autoAllocResult, sourcesArray);
17102
- const intents = buildAutoAllocatedBurnIntents(normalizedAutoAllocations, destChain, recipientAddress, params.token, params.config?.customFee);
17103
- const allocations = [
17104
- ...normalizedAutoAllocations.user,
17105
- ...normalizedAutoAllocations.devFee,
17106
- ...normalizedAutoAllocations.circleFee
17107
- ];
17312
+ }));
17313
+ const chainBalances = [];
17314
+ for(let i = 0; i < balanceResults.length; i++){
17315
+ const breakdowns = balanceResults[i]?.breakdown[0]?.breakdown ?? [];
17316
+ for (const b of breakdowns){
17317
+ chainBalances.push({
17318
+ chain: b.chain,
17319
+ confirmedBalance: b.confirmedBalance,
17320
+ sourceIndex: i
17321
+ });
17322
+ }
17323
+ }
17324
+ return chainBalances;
17325
+ }
17326
+ /**
17327
+ * Build auto-allocated normalised allocations and burn intents from
17328
+ * pre-fetched balances.
17329
+ *
17330
+ * Performs no balance API call, so it can be re-invoked with
17331
+ * `gasFeeOverrides` (the real per-chain gas from a prior estimate) to correct
17332
+ * an over-draw without re-querying balances.
17333
+ *
17334
+ * @param params - Spend parameters (source(s), token, optional custom fee).
17335
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17336
+ * @param recipientAddress - Resolved recipient address on the destination chain.
17337
+ * @param useForwarder - Whether the Forwarding Service path is active.
17338
+ * @param amountIn - Human-readable USDC amount to allocate.
17339
+ * @param chainBalances - Confirmed balances from {@link fetchChainBalances}.
17340
+ * @param gasFeeOverrides - Optional real per-chain gas fees to reserve.
17341
+ * @returns Normalised allocations and burn intents for the estimate/transfer API.
17342
+ */ async function buildAutoAllocatedFromBalances(params, destChain, recipientAddress, useForwarder, amountIn, chainBalances, gasFeeOverrides) {
17343
+ const rawSources = Array.isArray(params.from) ? params.from : [
17344
+ params.from
17345
+ ];
17346
+ const sourcesArray = rawSources.filter((s)=>s != null);
17347
+ const customFeeConfig = params.config?.customFee;
17348
+ const autoAllocResult = computeAutoAllocation({
17349
+ amountIn,
17350
+ destinationChain: destChain.chain,
17351
+ chainBalances,
17352
+ useForwarder,
17353
+ ...customFeeConfig ? {
17354
+ customFee: customFeeConfig
17355
+ } : {},
17356
+ ...gasFeeOverrides ? {
17357
+ gasFeeOverrides
17358
+ } : {}
17359
+ });
17360
+ const normalizedAutoAllocations = await normalizeAutoAllocations(autoAllocResult, sourcesArray);
17361
+ const intents = buildAutoAllocatedBurnIntents(normalizedAutoAllocations, destChain, recipientAddress, params.token, params.config?.customFee);
17362
+ const allocations = [
17363
+ ...normalizedAutoAllocations.user,
17364
+ ...normalizedAutoAllocations.devFee,
17365
+ ...normalizedAutoAllocations.circleFee
17366
+ ];
17367
+ return {
17368
+ allocations,
17369
+ intents
17370
+ };
17371
+ }
17372
+ /**
17373
+ * Resolve allocations and burn intents for the spend.
17374
+ *
17375
+ * Auto-allocation (`amountIn`) fetches balances once and returns them so the
17376
+ * caller can detect and correct over-draw without re-querying. Explicit
17377
+ * allocations return no balances (they are user-authoritative).
17378
+ *
17379
+ * @param params - Spend parameters.
17380
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17381
+ * @param recipientAddress - Resolved recipient address.
17382
+ * @param useForwarder - Whether the Forwarding Service path is active.
17383
+ * @returns Allocations, intents, and (auto-allocation only) confirmed balances.
17384
+ */ async function resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder) {
17385
+ if (params.amountIn) {
17386
+ const chainBalances = await fetchChainBalances(params, destChain);
17387
+ const { allocations, intents } = await buildAutoAllocatedFromBalances(params, destChain, recipientAddress, useForwarder, params.amountIn, chainBalances);
17108
17388
  return {
17109
17389
  allocations,
17110
- intents
17390
+ intents,
17391
+ chainBalances
17111
17392
  };
17112
17393
  }
17113
17394
  const allocations = await normalizeAllocations(params);
@@ -17149,6 +17430,148 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17149
17430
  forwardingFee: undefined
17150
17431
  };
17151
17432
  }
17433
+ /** Sum confirmed balances (atomic USDC) per source chain. */ function computeAvailablePerChain(chainBalances) {
17434
+ const available = new Map();
17435
+ for (const b of chainBalances){
17436
+ const atomic = parseUnits(b.confirmedBalance, USDC_DECIMALS$1);
17437
+ available.set(b.chain, (available.get(b.chain) ?? 0n) + atomic);
17438
+ }
17439
+ return available;
17440
+ }
17441
+ /**
17442
+ * Detect source chains whose required draw (value + maxFee across their
17443
+ * intents) exceeds the confirmed balance — the condition the Gateway API
17444
+ * rejects with `BALANCE_INSUFFICIENT_TOKEN` at `/v1/transfer`.
17445
+ *
17446
+ * Both sides are summed per chain (see {@link sumRequiredPerChain} and
17447
+ * {@link computeAvailablePerChain}), so detection is exact for the common
17448
+ * single-depositor-per-chain wallet. When multiple depositors hold USDC on the
17449
+ * same chain, a chain-level surplus can mask a per-depositor shortfall; the
17450
+ * API's own per-depositor `9001` remains the backstop in that case.
17451
+ */ function findOverdrawnChains(estimatedIntents, allocations, chainBalances) {
17452
+ const required = sumRequiredPerChain(estimatedIntents, allocations);
17453
+ const available = computeAvailablePerChain(chainBalances);
17454
+ const overdrawn = [];
17455
+ for (const [chain, req] of required){
17456
+ const avail = available.get(chain) ?? 0n;
17457
+ if (req > avail) {
17458
+ overdrawn.push({
17459
+ chain,
17460
+ required: req,
17461
+ available: avail
17462
+ });
17463
+ }
17464
+ }
17465
+ return overdrawn;
17466
+ }
17467
+ /**
17468
+ * Build a descriptive KitError for an auto-allocation gas shortfall that
17469
+ * survives the corrective re-allocation, naming the per-chain gap so the
17470
+ * caller sees the real cause instead of the opaque API 9001 rejection.
17471
+ */ function createAutoAllocationGasError(overdrawn, cause) {
17472
+ 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('; ');
17473
+ return new KitError({
17474
+ ...BalanceError.INSUFFICIENT_GAS,
17475
+ recoverability: 'FATAL',
17476
+ message: `Insufficient USDC to cover the transfer amount plus Gateway gas fees: ${detail}. ` + `Reduce the amount or add USDC on the affected chain(s).`,
17477
+ ...cause === undefined ? {} : {
17478
+ cause: {
17479
+ trace: {
17480
+ cause
17481
+ }
17482
+ }
17483
+ }
17484
+ });
17485
+ }
17486
+ /**
17487
+ * Validate allocations against the network/forwarder rules, call the estimate
17488
+ * API, and return the estimated intents with any forwarding fee.
17489
+ *
17490
+ * @param allocations - Normalised allocations for the estimate.
17491
+ * @param intents - Burn intents to estimate.
17492
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17493
+ * @param useForwarder - Whether the Forwarding Service path is active.
17494
+ * @returns Estimated intents (with real maxFee) and optional forwarding fee.
17495
+ */ async function validateAndEstimate(allocations, intents, destChain, useForwarder) {
17496
+ assertNetworkCompatibility(allocations, destChain);
17497
+ if (useForwarder) {
17498
+ assertForwarderRouteSupport(destChain, allocations);
17499
+ }
17500
+ const apiBaseUrl = getGatewayApiBaseUrl(destChain.isTestnet);
17501
+ const estimateBody = buildEstimateRequestBody(intents);
17502
+ const { entries, forwardingFee } = await fetchEstimate(apiBaseUrl, estimateBody, useForwarder, allocations);
17503
+ const estimatedIntents = parseEstimateResponse(entries, intents);
17504
+ return {
17505
+ estimatedIntents,
17506
+ forwardingFee
17507
+ };
17508
+ }
17509
+ /**
17510
+ * Fold newly-observed per-chain gas into the accumulated overrides, keeping the
17511
+ * higher fee per chain so a chain a later pass reveals is never under-reserved.
17512
+ */ function mergeGasFeeOverrides(base, next) {
17513
+ const merged = new Map(base);
17514
+ for (const [chain, fee] of next){
17515
+ const prev = merged.get(chain);
17516
+ if (prev === undefined || fee > prev) {
17517
+ merged.set(chain, fee);
17518
+ }
17519
+ }
17520
+ return merged;
17521
+ }
17522
+ /**
17523
+ * Maximum corrective re-allocation passes before failing fast. One pass fixes
17524
+ * the common case; a second/third covers a chain that a spill only introduces
17525
+ * after gas is reserved. Bounds the worst case at this many extra estimate
17526
+ * round-trips (only ever reached when the balance genuinely falls short).
17527
+ */ const MAX_CORRECTION_PASSES = 3;
17528
+ /**
17529
+ * Correct an auto-allocation over-draw: reserve the estimate's real per-chain
17530
+ * gas, re-allocate from the same balances, and re-estimate — repeating up to
17531
+ * {@link MAX_CORRECTION_PASSES} times, accumulating the real gas each pass
17532
+ * reveals.
17533
+ *
17534
+ * One pass fixes the common case, where the over-drawn chain was already in the
17535
+ * first estimate. A further pass covers a chain that a spill only introduces
17536
+ * once gas is reserved on the destination: that chain isn't in the first
17537
+ * estimate, so its real gas is unknown until it appears, and its first
17538
+ * re-allocation falls back to the static reserve. Each pass folds the newly
17539
+ * revealed gas into the overrides (see {@link mergeGasFeeOverrides}) so the
17540
+ * next pass reserves it too. Per-chain gas is ~amount-independent, so once
17541
+ * every drawn chain's real gas is known the allocation converges.
17542
+ *
17543
+ * When the shortfall is genuine — the re-allocation can't cover the amount, or
17544
+ * the passes are exhausted while still over-drawn — throws a gas-specific
17545
+ * {@link KitError} instead of submitting a doomed transfer.
17546
+ */ async function correctOverdraw(opts) {
17547
+ let overrides = deriveGasFeeOverrides(opts.estimatedIntents, opts.allocations);
17548
+ let overdrawn = opts.overdrawn;
17549
+ for(let pass = 0; pass < MAX_CORRECTION_PASSES; pass++){
17550
+ let corrected;
17551
+ try {
17552
+ corrected = await buildAutoAllocatedFromBalances(opts.params, opts.destChain, opts.recipientAddress, opts.useForwarder, opts.amountIn, opts.chainBalances, overrides);
17553
+ } catch (err) {
17554
+ // Re-allocating with the real gas reserved can't cover the amount →
17555
+ // surface a gas-specific error instead of the opaque API rejection.
17556
+ if (err instanceof KitError && err.code === BalanceError.INSUFFICIENT_TOKEN.code) {
17557
+ throw createAutoAllocationGasError(overdrawn, err);
17558
+ }
17559
+ throw err;
17560
+ }
17561
+ const { estimatedIntents, forwardingFee } = await validateAndEstimate(corrected.allocations, corrected.intents, opts.destChain, opts.useForwarder);
17562
+ const stillOverdrawn = findOverdrawnChains(estimatedIntents, corrected.allocations, opts.chainBalances);
17563
+ if (stillOverdrawn.length === 0) {
17564
+ return {
17565
+ allocations: corrected.allocations,
17566
+ estimatedIntents,
17567
+ forwardingFee
17568
+ };
17569
+ }
17570
+ overdrawn = stillOverdrawn;
17571
+ overrides = mergeGasFeeOverrides(overrides, deriveGasFeeOverrides(estimatedIntents, corrected.allocations));
17572
+ }
17573
+ throw createAutoAllocationGasError(overdrawn);
17574
+ }
17152
17575
  /**
17153
17576
  * Validate allocations, call the estimate API, and return estimated intents.
17154
17577
  *
@@ -17156,6 +17579,14 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17156
17579
  * path. Handles forwarder route validation, network compatibility, and the
17157
17580
  * estimate API call.
17158
17581
  *
17582
+ * For auto-allocation (`amountIn`), the greedy allocator reserves a static
17583
+ * per-chain gas fee that can undershoot the API's real fee, draining a source
17584
+ * (typically the destination chain) below `value + maxFee` and triggering a
17585
+ * `BALANCE_INSUFFICIENT_TOKEN` rejection. When the first estimate reveals such
17586
+ * an over-draw, a bounded corrective re-allocation reserves the real gas and
17587
+ * re-estimates until it converges or fails fast (see {@link correctOverdraw}).
17588
+ * Explicit allocations are user-authoritative and never re-allocated.
17589
+ *
17159
17590
  * @param params - Spend parameters.
17160
17591
  * @param destChain - Resolved destination chain with Gateway v1 config.
17161
17592
  * @param recipientAddress - Resolved recipient address.
@@ -17165,15 +17596,24 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17165
17596
  if (useForwarder) {
17166
17597
  assertForwarderRouteSupport(destChain);
17167
17598
  }
17168
- const { allocations, intents } = await resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder);
17169
- assertNetworkCompatibility(allocations, destChain);
17170
- if (useForwarder) {
17171
- assertForwarderRouteSupport(destChain, allocations);
17599
+ const { allocations, intents, chainBalances } = await resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder);
17600
+ const { estimatedIntents, forwardingFee } = await validateAndEstimate(allocations, intents, destChain, useForwarder);
17601
+ if (params.amountIn && chainBalances) {
17602
+ const overdrawn = findOverdrawnChains(estimatedIntents, allocations, chainBalances);
17603
+ if (overdrawn.length > 0) {
17604
+ return correctOverdraw({
17605
+ params,
17606
+ destChain,
17607
+ recipientAddress,
17608
+ useForwarder,
17609
+ amountIn: params.amountIn,
17610
+ chainBalances,
17611
+ estimatedIntents,
17612
+ allocations,
17613
+ overdrawn
17614
+ });
17615
+ }
17172
17616
  }
17173
- const apiBaseUrl = getGatewayApiBaseUrl(destChain.isTestnet);
17174
- const estimateBody = buildEstimateRequestBody(intents);
17175
- const { entries, forwardingFee } = await fetchEstimate(apiBaseUrl, estimateBody, useForwarder, allocations);
17176
- const estimatedIntents = parseEstimateResponse(entries, intents);
17177
17617
  return {
17178
17618
  allocations,
17179
17619
  estimatedIntents,
@@ -18081,8 +18521,10 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18081
18521
  *
18082
18522
  * - `computeFee` — required function that receives resolved spend params
18083
18523
  * and returns a fee as a string (or `Promise<string>`).
18084
- * - `resolveFeeRecipientAddress` — required function that returns a
18085
- * recipient address as a string (or `Promise<string>`).
18524
+ * - `resolveFeeRecipientAddress` — optional function that returns a
18525
+ * recipient address as a string (or `Promise<string>`). Omit it when
18526
+ * using `setFeeRecipients()`'s declarative map instead — a policy
18527
+ * with neither throws at spend time.
18086
18528
  *
18087
18529
  * @example
18088
18530
  * ```ts
@@ -18095,7 +18537,7 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18095
18537
  * ```
18096
18538
  */ const customFeePolicySchema = zod.z.object({
18097
18539
  computeFee: zod.z.function().returns(zod.z.string().or(zod.z.promise(zod.z.string()))),
18098
- resolveFeeRecipientAddress: zod.z.function().returns(zod.z.string().or(zod.z.promise(zod.z.string())))
18540
+ resolveFeeRecipientAddress: zod.z.function().returns(zod.z.string().or(zod.z.promise(zod.z.string()))).optional()
18099
18541
  }).strict();
18100
18542
  /**
18101
18543
  * Assert that the provided value conforms to {@link CustomFeePolicy}.
@@ -18117,6 +18559,71 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18117
18559
  validateWithStateTracking(config, customFeePolicySchema, 'UnifiedBalanceKit custom fee policy', assertCustomFeePolicySymbol);
18118
18560
  }
18119
18561
 
18562
+ const assertFeeRecipientsConfigSymbol = Symbol('assertFeeRecipientsConfig');
18563
+ /**
18564
+ * Schema for validating {@link FeeRecipientsConfig}.
18565
+ *
18566
+ * Requires at least one of `evm`/`solana`, non-empty string values for
18567
+ * whichever keys are present, and — mirroring the `depositAccount`
18568
+ * validation in `deposit/validate/assertions` — an address format that
18569
+ * matches the given chain type (EVM hex vs Solana base58).
18570
+ *
18571
+ * @example
18572
+ * ```ts
18573
+ * const config = {
18574
+ * evm: '0x1234567890123456789012345678901234567890',
18575
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
18576
+ * }
18577
+ * const result = feeRecipientsConfigSchema.safeParse(config)
18578
+ * // result.success === true
18579
+ * ```
18580
+ */ const feeRecipientsConfigSchema = zod.z.object({
18581
+ evm: zod.z.string().min(1, 'Fee recipient address is required.').optional(),
18582
+ solana: zod.z.string().min(1, 'Fee recipient address is required.').optional()
18583
+ }).strict().refine((config)=>Object.keys(config).length > 0, {
18584
+ message: 'At least one fee recipient (evm or solana) is required.'
18585
+ }).superRefine((config, ctx)=>{
18586
+ for (const type of Object.keys(config)){
18587
+ const address = config[type];
18588
+ if (address == null) continue;
18589
+ // `{ name: type, type }` is a placeholder chain identifier — only
18590
+ // `.type` is checked by these two helpers today, `.name` is unused.
18591
+ // No real ChainDefinition exists here, since validation runs before
18592
+ // a destination chain is resolved.
18593
+ if (!isValidAddressForChain(address, {
18594
+ name: type,
18595
+ type
18596
+ })) {
18597
+ const { expectedAddressFormat } = extractChainInfo({
18598
+ name: type,
18599
+ type
18600
+ });
18601
+ ctx.addIssue({
18602
+ code: zod.z.ZodIssueCode.custom,
18603
+ path: [
18604
+ type
18605
+ ],
18606
+ message: `Invalid ${type} address "${address}". Expected ${expectedAddressFormat}.`
18607
+ });
18608
+ }
18609
+ }
18610
+ });
18611
+ /**
18612
+ * Assert that the provided value conforms to {@link FeeRecipientsConfig}.
18613
+ *
18614
+ * Throws a validation error with annotated paths if the configuration is
18615
+ * malformed.
18616
+ *
18617
+ * @param config - The fee recipients map to validate.
18618
+ *
18619
+ * @example
18620
+ * ```ts
18621
+ * assertFeeRecipientsConfig({ evm: '0x1234567890123456789012345678901234567890' })
18622
+ * ```
18623
+ */ function assertFeeRecipientsConfig(config) {
18624
+ validateWithStateTracking(config, feeRecipientsConfigSchema, 'UnifiedBalanceKit fee recipients config', assertFeeRecipientsConfigSymbol);
18625
+ }
18626
+
18120
18627
  function sameChain(a, b) {
18121
18628
  return a.chain !== undefined && a.chain === b.chain;
18122
18629
  }
@@ -19097,6 +19604,105 @@ function assertSourceAccountAddresses(from) {
19097
19604
  config: params.config
19098
19605
  };
19099
19606
  }
19607
+ /**
19608
+ * Tracks, per {@link CustomFeePolicy} instance, which chain types have
19609
+ * already triggered the "falling back to resolveFeeRecipientAddress"
19610
+ * warning, so repeated `spend()`/`estimateSpend()` calls (e.g. live
19611
+ * quoting) warn once per (policy, chain type) pair rather than on every
19612
+ * call.
19613
+ */ const warnedFeeRecipientFallbacks = new WeakMap();
19614
+ /**
19615
+ * Invoke `resolveFeeRecipientAddress` and validate its return value has a
19616
+ * plausible address format for `destChain`, the same check
19617
+ * `setFeeRecipients()` already applies at config time. Unlike the map,
19618
+ * the callback's return value can't be validated ahead of time, so it's
19619
+ * checked here instead — a malformed value throws immediately rather
19620
+ * than silently becoming the fee recipient.
19621
+ *
19622
+ * @internal
19623
+ */ async function resolveFeeRecipientFromCallback(callback, destChain, params) {
19624
+ const address = await callback(destChain, params);
19625
+ if (!isValidAddressForChain(address, destChain)) {
19626
+ throw new KitError({
19627
+ ...InputError.VALIDATION_FAILED,
19628
+ recoverability: 'FATAL',
19629
+ message: `resolveFeeRecipientAddress returned an invalid address ` + `"${address}" for chain type "${destChain.type}" ` + `(resolved destination: ${destChain.name}).`
19630
+ });
19631
+ }
19632
+ return address;
19633
+ }
19634
+ /**
19635
+ * Resolve the single fee recipient address for a spend.
19636
+ *
19637
+ * Every fee burn intent in a spend mints to the same destination
19638
+ * chain regardless of which source chain(s) funded it, so exactly one
19639
+ * recipient address — valid on `destChain` — is ever needed.
19640
+ *
19641
+ * `feeRecipients` (set via `setFeeRecipients`) takes priority over the
19642
+ * policy's `resolveFeeRecipientAddress` callback for any chain type it
19643
+ * has an entry for, since it's a direct lookup and doesn't require
19644
+ * invoking developer code. For a chain type `feeRecipients` doesn't
19645
+ * cover, it falls back to `resolveFeeRecipientAddress` if one is
19646
+ * configured — a warning is logged once per (policy, chain type) pair
19647
+ * so the fallback isn't a silent surprise, without spamming repeated
19648
+ * `estimateSpend()` calls used for live quoting. Throws if neither
19649
+ * resolves `destChain`'s type, or if `resolveFeeRecipientAddress`
19650
+ * resolves it to a malformed address (see
19651
+ * {@link resolveFeeRecipientFromCallback}).
19652
+ *
19653
+ * @internal
19654
+ */ async function resolveFeeRecipient(destChain, policy, feeRecipients, params) {
19655
+ if (feeRecipients) {
19656
+ // `destChain.type` is `@core/chains`' broader `ChainType` union;
19657
+ // `FeeRecipientChainType` is the narrower subset this map supports
19658
+ // today. A type not present as a key simply has no configured
19659
+ // recipient, which is handled below.
19660
+ const type = destChain.type;
19661
+ const recipientAddress = feeRecipients[type];
19662
+ if (recipientAddress) {
19663
+ return recipientAddress;
19664
+ }
19665
+ if (policy.resolveFeeRecipientAddress) {
19666
+ const warnedTypes = warnedFeeRecipientFallbacks.get(policy);
19667
+ if (!warnedTypes?.has(type)) {
19668
+ warnedFeeRecipientFallbacks.set(policy, (warnedTypes ?? new Set()).add(type));
19669
+ 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.`);
19670
+ }
19671
+ return resolveFeeRecipientFromCallback(policy.resolveFeeRecipientAddress, destChain, params);
19672
+ }
19673
+ throw new KitError({
19674
+ ...InputError.VALIDATION_FAILED,
19675
+ recoverability: 'FATAL',
19676
+ 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.`
19677
+ });
19678
+ }
19679
+ if (!policy.resolveFeeRecipientAddress) {
19680
+ throw new KitError({
19681
+ ...InputError.VALIDATION_FAILED,
19682
+ recoverability: 'FATAL',
19683
+ message: 'No fee recipient configured — call setFeeRecipients() or provide ' + 'resolveFeeRecipientAddress on the custom fee policy.'
19684
+ });
19685
+ }
19686
+ return resolveFeeRecipientFromCallback(policy.resolveFeeRecipientAddress, destChain, params);
19687
+ }
19688
+ /**
19689
+ * Guard against a common misconfiguration: a developer sets the
19690
+ * declarative `feeRecipients` map expecting it alone to drive fee
19691
+ * collection, but no fee is ever charged without a `computeFee` from
19692
+ * `customFeePolicy` to determine the amount. Without this check that
19693
+ * misconfiguration fails silently — no fee is charged and no error is
19694
+ * raised.
19695
+ *
19696
+ * @internal
19697
+ */ function assertFeeRecipientsHasPolicy(feeRecipients) {
19698
+ if (feeRecipients) {
19699
+ throw new KitError({
19700
+ ...InputError.VALIDATION_FAILED,
19701
+ recoverability: 'FATAL',
19702
+ 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.'
19703
+ });
19704
+ }
19705
+ }
19100
19706
  /**
19101
19707
  * Apply a {@link CustomFeePolicy} to an adapter-only spend.
19102
19708
  *
@@ -19105,15 +19711,20 @@ function assertSourceAccountAddresses(from) {
19105
19711
  * `config.customFee` so the provider sees it.
19106
19712
  *
19107
19713
  * @internal
19108
- */ async function mergeCustomFeePolicyForAdapterOnly(params, policy) {
19109
- if (params.config?.customFee || !policy) {
19714
+ */ async function mergeCustomFeePolicyForAdapterOnly(params, policy, feeRecipients) {
19715
+ if (params.config?.customFee) {
19716
+ return params;
19717
+ }
19718
+ if (!policy) {
19719
+ assertFeeRecipientsHasPolicy(feeRecipients);
19110
19720
  return params;
19111
19721
  }
19112
19722
  const destChain = resolveChainIdentifier(params.to.chain);
19113
- const [feeValue, recipientAddress] = await Promise.all([
19114
- policy.computeFee(params),
19115
- policy.resolveFeeRecipientAddress(destChain, params)
19116
- ]);
19723
+ // Resolve the recipient before computing the fee: a KitError here
19724
+ // (missing/unresolvable recipient) shouldn't be preceded by an
19725
+ // otherwise-wasted computeFee call, which may be a network request.
19726
+ const recipientAddress = await resolveFeeRecipient(destChain, policy, feeRecipients, params);
19727
+ const feeValue = await policy.computeFee(params);
19117
19728
  return {
19118
19729
  ...params,
19119
19730
  config: {
@@ -19143,18 +19754,35 @@ function assertSourceAccountAddresses(from) {
19143
19754
  });
19144
19755
  }
19145
19756
  }
19146
- async function mergeCustomFeeConfig(resolved, policy) {
19147
- if (resolved.config?.customFee || !policy) {
19757
+ async function mergeCustomFeeConfig(resolved, policy, feeRecipients) {
19758
+ if (resolved.config?.customFee) {
19148
19759
  return resolved;
19149
19760
  }
19150
- const firstSourceChain = resolved.from[0]?.allocations[0]?.chain;
19151
- if (!firstSourceChain) {
19761
+ if (!policy) {
19762
+ assertFeeRecipientsHasPolicy(feeRecipients);
19152
19763
  return resolved;
19153
19764
  }
19154
- const [feeValue, recipientAddress] = await Promise.all([
19155
- policy.computeFee(resolved),
19156
- policy.resolveFeeRecipientAddress(firstSourceChain, resolved)
19157
- ]);
19765
+ // Skip fee resolution when there's no source chain to spend from at
19766
+ // all. This state can't arise from validated input today — the
19767
+ // caller re-checks and throws "No source chain found" right after
19768
+ // this returns — but skipping here isn't dead code: verified that
19769
+ // removing it lets a degenerate zero-allocation resolved value reach
19770
+ // computeFee/assertDeveloperFeeWithinBounds first, which throws a
19771
+ // misleading "Developer fee must be less than the total spend
19772
+ // amount" (0 >= 0 total allocation) instead of the correct "No
19773
+ // source chain found" error — or, for a real developer computeFee
19774
+ // that assumes a non-empty allocation, an uncaught raw exception
19775
+ // instead of any KitError at all. This guard exists to guarantee the
19776
+ // caller's clear error is what actually surfaces, not for
19777
+ // correctness.
19778
+ if (collectSourceChains(resolved).length === 0) {
19779
+ return resolved;
19780
+ }
19781
+ // Resolve the recipient before computing the fee: a KitError here
19782
+ // (missing/unresolvable recipient) shouldn't be preceded by an
19783
+ // otherwise-wasted computeFee call, which may be a network request.
19784
+ const recipientAddress = await resolveFeeRecipient(resolved.to.chain, policy, feeRecipients, resolved);
19785
+ const feeValue = await policy.computeFee(resolved);
19158
19786
  return {
19159
19787
  ...resolved,
19160
19788
  config: {
@@ -19210,14 +19838,14 @@ async function mergeCustomFeeConfig(resolved, policy) {
19210
19838
  }
19211
19839
  const destChain = resolveChainIdentifier(params.to.chain);
19212
19840
  if (!hasExplicitAllocations(params.from)) {
19213
- const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy);
19841
+ const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy, context.feeRecipients);
19214
19842
  assertDeveloperFeeWithinAmount(merged);
19215
19843
  const provider = findProviderForChain(context, normalizeToken(merged.token), destChain);
19216
19844
  return callSpend(provider, toProviderAdapterOnlyParams(merged));
19217
19845
  }
19218
19846
  const resolved = await resolveSpendParams(params);
19219
19847
  assertSpendNetworkCompatibility(resolved);
19220
- const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy);
19848
+ const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy, context.feeRecipients);
19221
19849
  assertDeveloperFeeWithinBounds(withFee);
19222
19850
  const sourceChains = collectSourceChains(withFee);
19223
19851
  if (sourceChains.length === 0) {
@@ -19257,14 +19885,14 @@ async function mergeCustomFeeConfig(resolved, policy) {
19257
19885
  assertSpendParams(params);
19258
19886
  const destChain = resolveChainIdentifier(params.to.chain);
19259
19887
  if (!hasExplicitAllocations(params.from)) {
19260
- const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy);
19888
+ const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy, context.feeRecipients);
19261
19889
  assertDeveloperFeeWithinAmount(merged);
19262
19890
  const provider = findProviderForChain(context, normalizeToken(merged.token), destChain);
19263
19891
  return provider.estimateSpend(toProviderAdapterOnlyParams(merged));
19264
19892
  }
19265
19893
  const resolved = await resolveSpendParams(params);
19266
19894
  assertSpendNetworkCompatibility(resolved);
19267
- const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy);
19895
+ const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy, context.feeRecipients);
19268
19896
  assertDeveloperFeeWithinBounds(withFee);
19269
19897
  const sourceChains = collectSourceChains(withFee);
19270
19898
  if (sourceChains.length === 0) {
@@ -20239,6 +20867,46 @@ const removeFundParamsSchema = zod.z.object({
20239
20867
  */ removeCustomFeePolicy() {
20240
20868
  delete this.context.customFeePolicy;
20241
20869
  }
20870
+ /**
20871
+ * Set a declarative fee recipient map, keyed by chain type. Once set,
20872
+ * `spend()`/`estimateSpend()` resolve the fee recipient by looking up
20873
+ * the spend's destination chain type in this map — taking priority
20874
+ * over `customFeePolicy`'s `resolveFeeRecipientAddress` callback.
20875
+ *
20876
+ * @remarks
20877
+ * This only controls which address a fee is sent to — it does not by
20878
+ * itself cause any fee to be charged. You still need
20879
+ * {@link UnifiedBalanceKit.setCustomFeePolicy}'s `computeFee` to
20880
+ * determine the fee amount; calling `setFeeRecipients` without ever
20881
+ * calling `setCustomFeePolicy` throws at spend time (there is no
20882
+ * `computeFee` to determine an amount).
20883
+ *
20884
+ * @param config - Fee recipient addresses keyed by chain type (e.g.
20885
+ * `{ evm: '0x...', solana: 'Sol...' }`). Provide entries for every
20886
+ * chain type you expect to spend to; spending to a chain type with
20887
+ * no matching entry throws before any fee collection is attempted.
20888
+ *
20889
+ * @example
20890
+ * ```typescript
20891
+ * kit.setFeeRecipients({
20892
+ * evm: '0x1234567890123456789012345678901234567890',
20893
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
20894
+ * })
20895
+ * ```
20896
+ */ setFeeRecipients(config) {
20897
+ assertFeeRecipientsConfig(config);
20898
+ this.context.feeRecipients = config;
20899
+ }
20900
+ /**
20901
+ * Remove the declarative fee recipient map for the kit.
20902
+ *
20903
+ * @example
20904
+ * ```typescript
20905
+ * kit.removeFeeRecipients()
20906
+ * ```
20907
+ */ removeFeeRecipients() {
20908
+ delete this.context.feeRecipients;
20909
+ }
20242
20910
  }
20243
20911
 
20244
20912
  // Auto-register this kit for user agent tracking
@@ -20565,6 +21233,45 @@ registerKit(`${pkg.name}/${pkg.version}`);
20565
21233
  */ removeCustomFeePolicy() {
20566
21234
  this.kit.removeCustomFeePolicy();
20567
21235
  }
21236
+ /**
21237
+ * Set a declarative fee recipient map, keyed by chain type.
21238
+ *
21239
+ * Once set, `spend()`/`estimateSpend()` resolve the fee recipient by
21240
+ * looking up the spend's destination chain type in this map — taking
21241
+ * priority over `customFeePolicy`'s `resolveFeeRecipientAddress`
21242
+ * callback.
21243
+ *
21244
+ * @remarks
21245
+ * This only controls which address a fee is sent to — it does not by
21246
+ * itself cause any fee to be charged. You still need
21247
+ * `setCustomFeePolicy`'s `computeFee` to determine the fee amount;
21248
+ * calling `setFeeRecipients` without ever calling `setCustomFeePolicy`
21249
+ * throws at spend time (there is no `computeFee` to determine an
21250
+ * amount).
21251
+ *
21252
+ * @param config - Fee recipient addresses keyed by chain type (e.g.
21253
+ * `{ evm: '0x...', solana: 'Sol...' }`).
21254
+ *
21255
+ * @example
21256
+ * ```typescript
21257
+ * kit.unifiedBalance.setFeeRecipients({
21258
+ * evm: '0x1234567890123456789012345678901234567890',
21259
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
21260
+ * })
21261
+ * ```
21262
+ */ setFeeRecipients(config) {
21263
+ this.kit.setFeeRecipients(config);
21264
+ }
21265
+ /**
21266
+ * Remove the declarative fee recipient map.
21267
+ *
21268
+ * @example
21269
+ * ```typescript
21270
+ * kit.unifiedBalance.removeFeeRecipients()
21271
+ * ```
21272
+ */ removeFeeRecipients() {
21273
+ this.kit.removeFeeRecipients();
21274
+ }
20568
21275
  }
20569
21276
 
20570
21277
  exports.AppKitUnifiedBalance = AppKitUnifiedBalance;