@circle-fin/app-kit 1.9.0 → 1.11.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.
@@ -18,6 +18,17 @@
18
18
 
19
19
  'use strict';
20
20
 
21
+ // Buffer polyfill setup - executes before any other code
22
+ // Ensures globalThis.Buffer is available for Solana libraries
23
+ const { Buffer } = require('buffer');
24
+ if (typeof globalThis !== 'undefined' && typeof globalThis.Buffer === 'undefined') {
25
+ globalThis.Buffer = Buffer;
26
+ }
27
+ if (typeof window !== 'undefined' && typeof window.Buffer === 'undefined') {
28
+ window.Buffer = Buffer;
29
+ }
30
+
31
+
21
32
  var zod = require('zod');
22
33
  var web3_js = require('@solana/web3.js');
23
34
  require('bn.js');
@@ -47,6 +58,27 @@ var bs58__default = /*#__PURE__*/_interopDefault(bs58);
47
58
  * }
48
59
  * ```
49
60
  */ const isNodeEnvironment = ()=>typeof process !== 'undefined' && typeof process.versions === 'object' && typeof process.versions.node === 'string';
61
+ /**
62
+ * Return the SDK User-Agent request header only when running in Node.js.
63
+ *
64
+ * Browsers forbid manually setting `User-Agent`, and a custom fallback header
65
+ * can trigger CORS preflight. Non-Node server runtimes also omit this optional
66
+ * attribution header because they cannot set it reliably.
67
+ *
68
+ * @returns A User-Agent header in Node.js, or an empty object otherwise.
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * import { getNodeUserAgentHeader } from '@core/utils'
73
+ *
74
+ * const headers = {
75
+ * 'Content-Type': 'application/json',
76
+ * ...getNodeUserAgentHeader(),
77
+ * }
78
+ * ```
79
+ */ const getNodeUserAgentHeader = ()=>isNodeEnvironment() ? {
80
+ 'User-Agent': getUserAgent()
81
+ } : {};
50
82
  /**
51
83
  * Detect the runtime environment and return a shortened identifier.
52
84
  *
@@ -2874,7 +2906,10 @@ var EarnChain;
2874
2906
  contracts: {
2875
2907
  v1: {
2876
2908
  wallet: GATEWAY_WALLET_EVM_TESTNET,
2877
- minter: GATEWAY_MINTER_EVM_TESTNET
2909
+ minter: GATEWAY_MINTER_EVM_TESTNET,
2910
+ // DepositForHandler the GenericExecutor calls to run a fast cross-chain
2911
+ // deposit into the GatewayWallet above.
2912
+ depositForHandler: '0xD05E7D2E7d30b92c5F17d7d0fC575fce231F1A48'
2878
2913
  }
2879
2914
  },
2880
2915
  forwarderSupported: {
@@ -5958,7 +5993,10 @@ var Chains = {
5958
5993
  minter: zod.z.string({
5959
5994
  required_error: 'Gateway minter address is required. Please provide a valid contract address.',
5960
5995
  invalid_type_error: 'Gateway minter address must be a string.'
5961
- }).min(1, 'Gateway minter address cannot be empty.')
5996
+ }).min(1, 'Gateway minter address cannot be empty.'),
5997
+ depositForHandler: zod.z.string({
5998
+ invalid_type_error: 'Gateway depositForHandler address must be a string.'
5999
+ }).min(1, 'Gateway depositForHandler address cannot be empty.').optional()
5962
6000
  }).strict() // Reject any additional properties not defined in the schema
5963
6001
  ;
5964
6002
  /**
@@ -7080,13 +7118,12 @@ const swapTokenEnumSchema = zod.z.enum([
7080
7118
  headers: {
7081
7119
  ...DEFAULT_CONFIG.headers,
7082
7120
  ...config.headers ?? {},
7083
- // In browser environments, directly setting the 'User-Agent' or similar headers is restricted and may be ignored or cause errors.
7084
- // This is why we use the 'X-User-Agent' header instead.
7085
- ...typeof window === 'undefined' ? {
7086
- 'User-Agent': getUserAgent()
7087
- } : {
7088
- 'X-User-Agent': getUserAgent()
7089
- }
7121
+ // Browsers forbid setting a user-agent request header, and the custom
7122
+ // fallback header the SDK used instead trips CORS preflight against the
7123
+ // Circle APIs (it isn't in their `Access-Control-Allow-Headers`),
7124
+ // blocking the request. So send the SDK user agent only in Node;
7125
+ // browsers omit it entirely.
7126
+ ...getNodeUserAgentHeader()
7090
7127
  }
7091
7128
  };
7092
7129
  let lastError;
@@ -8358,6 +8395,7 @@ function parseOrThrow(value, schema, context) {
8358
8395
  if (payload.tokenIn !== undefined) safe['tokenIn'] = payload.tokenIn;
8359
8396
  if (payload.tokenOut !== undefined) safe['tokenOut'] = payload.tokenOut;
8360
8397
  if (payload.txHash !== undefined) safe['txHash'] = payload.txHash;
8398
+ if (payload.correlationId !== undefined) safe['correlationId'] = payload.correlationId;
8361
8399
  if (payload.errorDetails !== undefined) {
8362
8400
  const errorDetails = {
8363
8401
  ...payload.errorDetails.errorCode !== undefined && {
@@ -8428,18 +8466,15 @@ function parseOrThrow(value, schema, context) {
8428
8466
  timeoutHandle.unref();
8429
8467
  }
8430
8468
  try {
8431
- const isNode = isNodeEnvironment();
8432
- const userAgent = getUserAgent();
8433
8469
  await fetch(getLogsUrl(), {
8434
8470
  method: 'POST',
8435
8471
  headers: {
8436
8472
  'Content-Type': 'application/json',
8437
- // Browser restricts setting User-Agent; use X-User-Agent instead.
8438
- ...isNode ? {
8439
- 'User-Agent': userAgent
8440
- } : {
8441
- 'X-User-Agent': userAgent
8442
- }
8473
+ // Browsers forbid setting a user-agent request header, and the custom
8474
+ // fallback header the SDK used instead trips CORS preflight (it isn't
8475
+ // in the telemetry endpoint's `Access-Control-Allow-Headers`), so send
8476
+ // it only in Node; browsers omit it entirely.
8477
+ ...getNodeUserAgentHeader()
8443
8478
  },
8444
8479
  body: JSON.stringify(toSafePayload(payload)),
8445
8480
  signal: controller.signal
@@ -8652,7 +8687,7 @@ function parseOrThrow(value, schema, context) {
8652
8687
  // discards the stack trace, nested `cause`, and any custom Error
8653
8688
  // properties — exactly the context an on-call needs when a
8654
8689
  // resolver-closure regression triggers this path.
8655
- console.warn(`[stablecoin-kits telemetry] dropped error event '${eventType}':`, cause);
8690
+ console.warn(`[stablecoin-kits telemetry] dropped event '${eventType}':`, cause);
8656
8691
  } catch {
8657
8692
  // console.warn itself throwing is the user's environment; nothing more we
8658
8693
  // can do without risking the original operation error.
@@ -8668,7 +8703,9 @@ function parseOrThrow(value, schema, context) {
8668
8703
  sdkVersion: config.sdkVersion,
8669
8704
  eventType,
8670
8705
  timestamp: new Date().toISOString(),
8671
- errorDetails,
8706
+ ...errorDetails !== undefined && {
8707
+ errorDetails
8708
+ },
8672
8709
  clientContext: buildClientContext(),
8673
8710
  ...context?.sourceChain != null && {
8674
8711
  sourceChain: context.sourceChain
@@ -8684,6 +8721,9 @@ function parseOrThrow(value, schema, context) {
8684
8721
  },
8685
8722
  ...context?.txHash != null && {
8686
8723
  txHash: context.txHash
8724
+ },
8725
+ ...context?.correlationId != null && {
8726
+ correlationId: context.correlationId
8687
8727
  }
8688
8728
  };
8689
8729
  }
@@ -8745,7 +8785,7 @@ function parseOrThrow(value, schema, context) {
8745
8785
  }
8746
8786
 
8747
8787
  var name = "@circle-fin/unified-balance-kit";
8748
- var version = "1.2.2";
8788
+ var version = "1.3.1";
8749
8789
  var pkg = {
8750
8790
  name: name,
8751
8791
  version: version};
@@ -16381,29 +16421,33 @@ const CIRCLE_BPS_DIVISOR = 10_000n;
16381
16421
  const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16382
16422
  /**
16383
16423
  * Return the estimated Gateway gas fee for a chain in USDC atomic units.
16384
- * Falls back to a conservative 0.1 USDC for unlisted chains.
16424
+ * Prefers an entry in `overrides` (the real per-chain fee derived from a
16425
+ * prior estimate), then the static {@link GAS_FEE_BY_CHAIN} constant, and
16426
+ * finally a conservative 0.1 USDC fallback for unlisted chains.
16385
16427
  *
16386
16428
  * @param chain - The source blockchain.
16429
+ * @param overrides - Optional real per-chain gas fees keyed by chain.
16387
16430
  * @returns Gas fee in USDC atomic units.
16388
- */ function getGasFee(chain) {
16389
- return GAS_FEE_BY_CHAIN.get(chain) ?? DEFAULT_GAS_FEE;
16431
+ */ function getGasFee(chain, overrides) {
16432
+ return overrides?.get(chain) ?? GAS_FEE_BY_CHAIN.get(chain) ?? DEFAULT_GAS_FEE;
16390
16433
  }
16391
16434
  /**
16392
16435
  * Return the estimated forwarder fee for the destination chain
16393
16436
  * (service fee + destination gas fee).
16394
16437
  *
16395
16438
  * @param destinationChain - The mint destination chain.
16439
+ * @param overrides - Optional real per-chain gas fees keyed by chain.
16396
16440
  * @returns Forwarder fee in USDC atomic units.
16397
- */ function getForwarderFee(destinationChain) {
16398
- const destGas = getGasFee(destinationChain);
16441
+ */ function getForwarderFee(destinationChain, overrides) {
16442
+ const destGas = getGasFee(destinationChain, overrides);
16399
16443
  return FORWARDER_SERVICE_FEE + destGas;
16400
16444
  }
16401
16445
  /**
16402
16446
  * Estimate the fixed fees (gas + forwarder) and compute the maximum
16403
16447
  * amount that can be drawn from this chain for a single intent,
16404
16448
  * accounting for the 0.5 bps transfer fee if cross-chain.
16405
- */ function computeMaxDrawable(slot, forwarderFeeRemaining) {
16406
- const gasFee = getGasFee(slot.chain);
16449
+ */ function computeMaxDrawable(slot, forwarderFeeRemaining, overrides) {
16450
+ const gasFee = getGasFee(slot.chain, overrides);
16407
16451
  let fixedFees = gasFee;
16408
16452
  let forwarderFeeUsed = 0n;
16409
16453
  if (forwarderFeeRemaining > 0n) {
@@ -16435,14 +16479,14 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16435
16479
  * buffer), and returns the allocations for this pass.
16436
16480
  *
16437
16481
  * Mutates `slot.remaining` so the next pass sees reduced balances.
16438
- */ function greedyAllocate(slots, amount, destinationChain, useForwarder) {
16482
+ */ function greedyAllocate(slots, amount, destinationChain, useForwarder, overrides) {
16439
16483
  const result = [];
16440
16484
  let remaining = amount;
16441
- let forwarderFeeRemaining = useForwarder ? getForwarderFee(destinationChain) : 0n;
16485
+ let forwarderFeeRemaining = useForwarder ? getForwarderFee(destinationChain, overrides) : 0n;
16442
16486
  if (remaining <= 0n) return result;
16443
16487
  for (const slot of slots){
16444
16488
  if (remaining <= 0n) break;
16445
- const { drawable, gasFee, forwarderFeeUsed } = computeMaxDrawable(slot, forwarderFeeRemaining);
16489
+ const { drawable, gasFee, forwarderFeeUsed } = computeMaxDrawable(slot, forwarderFeeRemaining, overrides);
16446
16490
  if (drawable <= 0n) continue;
16447
16491
  // Greedy: take as much as we can from this chain
16448
16492
  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.
@@ -16541,7 +16585,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16541
16585
  // After this pass, slot.remaining reflects consumed capacity.
16542
16586
  // -----------------------------------------------------------------------
16543
16587
  const transferAmount = parseUnits(ctx.amountIn, USDC_DECIMALS);
16544
- const allocations = greedyAllocate(slots, transferAmount, ctx.destinationChain, ctx.useForwarder);
16588
+ const allocations = greedyAllocate(slots, transferAmount, ctx.destinationChain, ctx.useForwarder, ctx.gasFeeOverrides);
16545
16589
  assertFullyAllocated(allocations, transferAmount, ctx.amountIn);
16546
16590
  // -----------------------------------------------------------------------
16547
16591
  // 4. Phase 2 — Allocate developer fee
@@ -16549,7 +16593,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16549
16593
  // Same-chain first here too — if the destination chain still has
16550
16594
  // capacity, use it (same-chain fee intent = cheapest gas).
16551
16595
  // -----------------------------------------------------------------------
16552
- const developerFeeAllocations = greedyAllocate(slots, devFeeAmount, ctx.destinationChain, false);
16596
+ const developerFeeAllocations = greedyAllocate(slots, devFeeAmount, ctx.destinationChain, false, ctx.gasFeeOverrides);
16553
16597
  if (devFeeAmount > 0n) {
16554
16598
  assertFullyAllocated(developerFeeAllocations, devFeeAmount, formatUnits(devFeeAmount.toString(), USDC_DECIMALS));
16555
16599
  }
@@ -16558,7 +16602,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16558
16602
  // Again same ordering, same shared reduced balances.
16559
16603
  // Same-chain first for the same reason.
16560
16604
  // -----------------------------------------------------------------------
16561
- const circleFeeAllocations = greedyAllocate(slots, circleFeeAmount, ctx.destinationChain, false);
16605
+ const circleFeeAllocations = greedyAllocate(slots, circleFeeAmount, ctx.destinationChain, false, ctx.gasFeeOverrides);
16562
16606
  if (circleFeeAmount > 0n) {
16563
16607
  assertFullyAllocated(circleFeeAllocations, circleFeeAmount, formatUnits(circleFeeAmount.toString(), USDC_DECIMALS));
16564
16608
  }
@@ -16657,10 +16701,12 @@ const BPS_DIVISOR = 100_000n;
16657
16701
  *
16658
16702
  * Unlike `findChainNameByDomain` (which returns the display `name`),
16659
16703
  * this returns `chain.chain` — the enum identifier expected by
16660
- * {@link FeeAllocation}.
16704
+ * {@link FeeAllocation}. Returns `undefined` when no allocation covers the
16705
+ * domain so callers skip the intent rather than bucketing it under a
16706
+ * fabricated sentinel.
16661
16707
  */ function findBlockchainByDomain(domain, allocations) {
16662
16708
  const alloc = allocations.find((a)=>a.chain.gateway.domain === domain);
16663
- return alloc?.chain.chain ?? 'Unknown';
16709
+ return alloc?.chain.chain;
16664
16710
  }
16665
16711
  /**
16666
16712
  * Normalize any address/salt format to lowercase bytes32 hex.
@@ -16784,6 +16830,33 @@ const BPS_DIVISOR = 100_000n;
16784
16830
  };
16785
16831
  });
16786
16832
  }
16833
+ /**
16834
+ * Read an intent's transfer value as a BigInt, tolerating the string form
16835
+ * that can appear on estimate-response specs.
16836
+ */ function intentValue(intent) {
16837
+ const { value } = intent.spec;
16838
+ return typeof value === 'bigint' ? value : safeBigInt(String(value), 'spec.value');
16839
+ }
16840
+ /**
16841
+ * Split a single intent's `maxFee` into its transfer-fee and gas-fee
16842
+ * components.
16843
+ *
16844
+ * `transferFee = value * GATEWAY_TRANSFER_FEE_SCALED_BPS / BPS_DIVISOR`
16845
+ * `gasFee = maxFee - transferFee`
16846
+ *
16847
+ * Same-chain transfers (withdrawals) do not incur a transfer fee, so the
16848
+ * whole `maxFee` is gas. See {@link aggregateFeesByIntent} for the caveats
16849
+ * on re-deriving the split locally.
16850
+ */ function splitIntentFee(intent) {
16851
+ const { maxFee, spec } = intent;
16852
+ const isSameChain = spec.sourceDomain === spec.destinationDomain;
16853
+ const transferFee = isSameChain ? 0n : intentValue(intent) * GATEWAY_TRANSFER_FEE_SCALED_BPS / BPS_DIVISOR;
16854
+ const gasFee = maxFee > transferFee ? maxFee - transferFee : 0n;
16855
+ return {
16856
+ transferFee,
16857
+ gasFee
16858
+ };
16859
+ }
16787
16860
  /**
16788
16861
  * Decompose each intent's `maxFee` into a transfer fee and a gas fee,
16789
16862
  * then aggregate both by source chain.
@@ -16808,7 +16881,6 @@ const BPS_DIVISOR = 100_000n;
16808
16881
  * names from source domains.
16809
16882
  * @returns Per-chain and total transfer/gas fee breakdowns.
16810
16883
  */ function aggregateFeesByIntent(estimatedIntents, allocations) {
16811
- const transferFeeBps = GATEWAY_TRANSFER_FEE_SCALED_BPS;
16812
16884
  const transferFeeByChain = new Map();
16813
16885
  const gasFeeByChain = new Map();
16814
16886
  let totalTransferFee = 0n;
@@ -16816,18 +16888,18 @@ const BPS_DIVISOR = 100_000n;
16816
16888
  for (const intent of estimatedIntents){
16817
16889
  const { maxFee, spec } = intent;
16818
16890
  if (maxFee === 0n) continue;
16819
- const chainName = findBlockchainByDomain(spec.sourceDomain, allocations);
16820
- const value = typeof spec.value === 'bigint' ? spec.value : safeBigInt(String(spec.value), 'spec.value');
16821
- const isSameChain = spec.sourceDomain === spec.destinationDomain;
16822
- const transferFee = isSameChain ? 0n : value * transferFeeBps / BPS_DIVISOR;
16823
- const gasFee = maxFee > transferFee ? maxFee - transferFee : 0n;
16891
+ const { transferFee, gasFee } = splitIntentFee(intent);
16892
+ totalTransferFee += transferFee;
16893
+ totalGasFee += gasFee;
16894
+ // Totals stay complete even if a domain can't be resolved; only the
16895
+ // per-chain breakdown skips it rather than inventing a placeholder chain.
16896
+ const chain = findBlockchainByDomain(spec.sourceDomain, allocations);
16897
+ if (chain === undefined) continue;
16824
16898
  if (transferFee > 0n) {
16825
- totalTransferFee += transferFee;
16826
- transferFeeByChain.set(chainName, (transferFeeByChain.get(chainName) ?? 0n) + transferFee);
16899
+ transferFeeByChain.set(chain, (transferFeeByChain.get(chain) ?? 0n) + transferFee);
16827
16900
  }
16828
16901
  if (gasFee > 0n) {
16829
- totalGasFee += gasFee;
16830
- gasFeeByChain.set(chainName, (gasFeeByChain.get(chainName) ?? 0n) + gasFee);
16902
+ gasFeeByChain.set(chain, (gasFeeByChain.get(chain) ?? 0n) + gasFee);
16831
16903
  }
16832
16904
  }
16833
16905
  return {
@@ -16891,6 +16963,91 @@ const BPS_DIVISOR = 100_000n;
16891
16963
  }
16892
16964
  return fees;
16893
16965
  }
16966
+ /**
16967
+ * Derive the real per-chain Gateway gas fee from estimated intents, keyed by
16968
+ * source {@link Blockchain}.
16969
+ *
16970
+ * The value is the maximum single-intent gas fee observed on each chain
16971
+ * (`maxFee − transferFee`) — the amount `computeAutoAllocation` must reserve
16972
+ * per burn intent on that chain. Gas is (near) amount-independent, so every
16973
+ * intent on a chain pays roughly the same; taking the max is a conservative
16974
+ * choice for the multi-intent-per-chain case.
16975
+ *
16976
+ * Intended for `AutoAllocationContext.gasFeeOverrides` so the corrective
16977
+ * re-allocation pass reserves the API's real fee instead of the static
16978
+ * {@link GAS_FEE_BY_CHAIN} constant.
16979
+ *
16980
+ * @param estimatedIntents - Intents with `maxFee` from {@link parseEstimateResponse}.
16981
+ * @param allocations - Normalised allocations used to resolve chain from source domain.
16982
+ * @returns Per-chain real gas fees in USDC atomic units.
16983
+ *
16984
+ * @example
16985
+ * ```typescript
16986
+ * import type { BurnIntent } from '../createIntent/types'
16987
+ * import type { NormalizedAllocation } from '../allocations'
16988
+ *
16989
+ * declare const estimatedIntents: BurnIntent[]
16990
+ * declare const allocations: NormalizedAllocation[]
16991
+ *
16992
+ * // Real per-chain gas, ready to pass as AutoAllocationContext.gasFeeOverrides
16993
+ * // to re-run computeAutoAllocation with the corrected reserve.
16994
+ * const overrides = deriveGasFeeOverrides(estimatedIntents, allocations)
16995
+ * ```
16996
+ */ function deriveGasFeeOverrides(estimatedIntents, allocations) {
16997
+ const overrides = new Map();
16998
+ for (const intent of estimatedIntents){
16999
+ if (intent.maxFee === 0n) continue;
17000
+ const chain = findBlockchainByDomain(intent.spec.sourceDomain, allocations);
17001
+ if (chain === undefined) continue;
17002
+ const { gasFee } = splitIntentFee(intent);
17003
+ const prev = overrides.get(chain) ?? 0n;
17004
+ if (gasFee > prev) overrides.set(chain, gasFee);
17005
+ }
17006
+ return overrides;
17007
+ }
17008
+ /**
17009
+ * Sum the total balance each source chain must cover, keyed by source
17010
+ * {@link Blockchain}.
17011
+ *
17012
+ * Approximates the Gateway API's balance validation, which rejects a transfer
17013
+ * (`BALANCE_INSUFFICIENT_TOKEN`) when a depositor's confirmed balance on a
17014
+ * source chain is below `sum(intent.value + intent.maxFee)` for that
17015
+ * depositor's intents. This aggregates by chain across all sources, so it is
17016
+ * exact for the common single-depositor-per-chain wallet. When several
17017
+ * depositors hold USDC on the same chain, the chain-level sum can mask a
17018
+ * per-depositor shortfall (or a surplus on one depositor can hide it); the
17019
+ * API's own per-depositor `9001` remains the backstop for that case. Scope the
17020
+ * comparison per (depositor, chain) if that multi-depositor case must be caught
17021
+ * pre-submit.
17022
+ *
17023
+ * @param estimatedIntents - Intents with `maxFee` from {@link parseEstimateResponse}.
17024
+ * @param allocations - Normalised allocations used to resolve chain from source domain.
17025
+ * @returns Per-chain required amount (transfer value + fees) in USDC atomic units.
17026
+ *
17027
+ * @example
17028
+ * ```typescript
17029
+ * import { Blockchain } from '@core/chains'
17030
+ * import type { BurnIntent } from '../createIntent/types'
17031
+ * import type { NormalizedAllocation } from '../allocations'
17032
+ *
17033
+ * declare const estimatedIntents: BurnIntent[]
17034
+ * declare const allocations: NormalizedAllocation[]
17035
+ * declare const confirmedBalanceAtomic: bigint
17036
+ *
17037
+ * const required = sumRequiredPerChain(estimatedIntents, allocations)
17038
+ * const overDrawn =
17039
+ * (required.get(Blockchain.Ethereum) ?? 0n) > confirmedBalanceAtomic
17040
+ * ```
17041
+ */ function sumRequiredPerChain(estimatedIntents, allocations) {
17042
+ const required = new Map();
17043
+ for (const intent of estimatedIntents){
17044
+ const chain = findBlockchainByDomain(intent.spec.sourceDomain, allocations);
17045
+ if (chain === undefined) continue;
17046
+ const amount = intentValue(intent) + intent.maxFee;
17047
+ required.set(chain, (required.get(chain) ?? 0n) + amount);
17048
+ }
17049
+ return required;
17050
+ }
16894
17051
 
16895
17052
  /**
16896
17053
  * Sign each adapter group: Solana one intent per signature, EVM batch per adapter.
@@ -17145,69 +17302,127 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17145
17302
  * non-forwarder transfer response is missing attestation or signature.
17146
17303
  * @throws KitError Propagated from adapter signing if the user rejects
17147
17304
  * or the signer is unavailable.
17148
- */ async function resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder) {
17149
- if (params.amountIn) {
17150
- const rawSources = Array.isArray(params.from) ? params.from : [
17151
- params.from
17152
- ];
17153
- const sourcesArray = rawSources.filter((s)=>s != null);
17154
- const networkType = destChain.isTestnet ? 'testnet' : 'mainnet';
17155
- const balanceResults = await Promise.all(sourcesArray.map(async (source)=>{
17156
- // When sourceAccount is set (delegate flow), scope the balance
17157
- // query to the Gateway depositor not the signer. Using the
17158
- // address-only path bypasses adapter address resolution, which
17159
- // would otherwise return the signer's balance (developer-
17160
- // controlled) or reject an explicit address (user-controlled).
17161
- let querySource;
17162
- if (source.sourceAccount) {
17163
- querySource = {
17164
- address: source.sourceAccount
17165
- };
17166
- } else {
17167
- querySource = {
17168
- adapter: source.adapter
17169
- };
17170
- if ('address' in source && source.address) {
17171
- querySource['address'] = source.address;
17172
- }
17173
- }
17174
- return getBalances$1({
17175
- token: params.token,
17176
- sources: querySource,
17177
- networkType
17178
- });
17179
- }));
17180
- const chainBalances = [];
17181
- for(let i = 0; i < balanceResults.length; i++){
17182
- const breakdowns = balanceResults[i]?.breakdown[0]?.breakdown ?? [];
17183
- for (const b of breakdowns){
17184
- chainBalances.push({
17185
- chain: b.chain,
17186
- confirmedBalance: b.confirmedBalance,
17187
- sourceIndex: i
17188
- });
17305
+ */ /**
17306
+ * Fetch confirmed per-chain USDC balances for every auto-allocation source.
17307
+ *
17308
+ * Used only on the `amountIn` (auto-allocation) path. Returns one
17309
+ * {@link ChainBalance} per (source, chain) pair so the greedy allocator — and
17310
+ * the corrective re-allocation pass — can reason about draw limits without a
17311
+ * second balance round-trip.
17312
+ *
17313
+ * @param params - Spend parameters (source(s) and token).
17314
+ * @param destChain - Resolved destination chain (used for network type).
17315
+ * @returns Confirmed balances tagged with their originating source index.
17316
+ */ async function fetchChainBalances(params, destChain) {
17317
+ const rawSources = Array.isArray(params.from) ? params.from : [
17318
+ params.from
17319
+ ];
17320
+ const sourcesArray = rawSources.filter((s)=>s != null);
17321
+ const networkType = destChain.isTestnet ? 'testnet' : 'mainnet';
17322
+ const balanceResults = await Promise.all(sourcesArray.map(async (source)=>{
17323
+ // When sourceAccount is set (delegate flow), scope the balance
17324
+ // query to the Gateway depositor — not the signer. Using the
17325
+ // address-only path bypasses adapter address resolution, which
17326
+ // would otherwise return the signer's balance (developer-
17327
+ // controlled) or reject an explicit address (user-controlled).
17328
+ let querySource;
17329
+ if (source.sourceAccount) {
17330
+ querySource = {
17331
+ address: source.sourceAccount
17332
+ };
17333
+ } else {
17334
+ querySource = {
17335
+ adapter: source.adapter
17336
+ };
17337
+ if ('address' in source && source.address) {
17338
+ querySource['address'] = source.address;
17189
17339
  }
17190
17340
  }
17191
- const customFeeConfig = params.config?.customFee;
17192
- const autoAllocResult = computeAutoAllocation({
17193
- amountIn: params.amountIn,
17194
- destinationChain: destChain.chain,
17195
- chainBalances,
17196
- useForwarder,
17197
- ...customFeeConfig ? {
17198
- customFee: customFeeConfig
17199
- } : {}
17341
+ return getBalances$1({
17342
+ token: params.token,
17343
+ sources: querySource,
17344
+ networkType
17200
17345
  });
17201
- const normalizedAutoAllocations = await normalizeAutoAllocations(autoAllocResult, sourcesArray);
17202
- const intents = buildAutoAllocatedBurnIntents(normalizedAutoAllocations, destChain, recipientAddress, params.token, params.config?.customFee);
17203
- const allocations = [
17204
- ...normalizedAutoAllocations.user,
17205
- ...normalizedAutoAllocations.devFee,
17206
- ...normalizedAutoAllocations.circleFee
17207
- ];
17346
+ }));
17347
+ const chainBalances = [];
17348
+ for(let i = 0; i < balanceResults.length; i++){
17349
+ const breakdowns = balanceResults[i]?.breakdown[0]?.breakdown ?? [];
17350
+ for (const b of breakdowns){
17351
+ chainBalances.push({
17352
+ chain: b.chain,
17353
+ confirmedBalance: b.confirmedBalance,
17354
+ sourceIndex: i
17355
+ });
17356
+ }
17357
+ }
17358
+ return chainBalances;
17359
+ }
17360
+ /**
17361
+ * Build auto-allocated normalised allocations and burn intents from
17362
+ * pre-fetched balances.
17363
+ *
17364
+ * Performs no balance API call, so it can be re-invoked with
17365
+ * `gasFeeOverrides` (the real per-chain gas from a prior estimate) to correct
17366
+ * an over-draw without re-querying balances.
17367
+ *
17368
+ * @param params - Spend parameters (source(s), token, optional custom fee).
17369
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17370
+ * @param recipientAddress - Resolved recipient address on the destination chain.
17371
+ * @param useForwarder - Whether the Forwarding Service path is active.
17372
+ * @param amountIn - Human-readable USDC amount to allocate.
17373
+ * @param chainBalances - Confirmed balances from {@link fetchChainBalances}.
17374
+ * @param gasFeeOverrides - Optional real per-chain gas fees to reserve.
17375
+ * @returns Normalised allocations and burn intents for the estimate/transfer API.
17376
+ */ async function buildAutoAllocatedFromBalances(params, destChain, recipientAddress, useForwarder, amountIn, chainBalances, gasFeeOverrides) {
17377
+ const rawSources = Array.isArray(params.from) ? params.from : [
17378
+ params.from
17379
+ ];
17380
+ const sourcesArray = rawSources.filter((s)=>s != null);
17381
+ const customFeeConfig = params.config?.customFee;
17382
+ const autoAllocResult = computeAutoAllocation({
17383
+ amountIn,
17384
+ destinationChain: destChain.chain,
17385
+ chainBalances,
17386
+ useForwarder,
17387
+ ...customFeeConfig ? {
17388
+ customFee: customFeeConfig
17389
+ } : {},
17390
+ ...gasFeeOverrides ? {
17391
+ gasFeeOverrides
17392
+ } : {}
17393
+ });
17394
+ const normalizedAutoAllocations = await normalizeAutoAllocations(autoAllocResult, sourcesArray);
17395
+ const intents = buildAutoAllocatedBurnIntents(normalizedAutoAllocations, destChain, recipientAddress, params.token, params.config?.customFee);
17396
+ const allocations = [
17397
+ ...normalizedAutoAllocations.user,
17398
+ ...normalizedAutoAllocations.devFee,
17399
+ ...normalizedAutoAllocations.circleFee
17400
+ ];
17401
+ return {
17402
+ allocations,
17403
+ intents
17404
+ };
17405
+ }
17406
+ /**
17407
+ * Resolve allocations and burn intents for the spend.
17408
+ *
17409
+ * Auto-allocation (`amountIn`) fetches balances once and returns them so the
17410
+ * caller can detect and correct over-draw without re-querying. Explicit
17411
+ * allocations return no balances (they are user-authoritative).
17412
+ *
17413
+ * @param params - Spend parameters.
17414
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17415
+ * @param recipientAddress - Resolved recipient address.
17416
+ * @param useForwarder - Whether the Forwarding Service path is active.
17417
+ * @returns Allocations, intents, and (auto-allocation only) confirmed balances.
17418
+ */ async function resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder) {
17419
+ if (params.amountIn) {
17420
+ const chainBalances = await fetchChainBalances(params, destChain);
17421
+ const { allocations, intents } = await buildAutoAllocatedFromBalances(params, destChain, recipientAddress, useForwarder, params.amountIn, chainBalances);
17208
17422
  return {
17209
17423
  allocations,
17210
- intents
17424
+ intents,
17425
+ chainBalances
17211
17426
  };
17212
17427
  }
17213
17428
  const allocations = await normalizeAllocations(params);
@@ -17249,6 +17464,148 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17249
17464
  forwardingFee: undefined
17250
17465
  };
17251
17466
  }
17467
+ /** Sum confirmed balances (atomic USDC) per source chain. */ function computeAvailablePerChain(chainBalances) {
17468
+ const available = new Map();
17469
+ for (const b of chainBalances){
17470
+ const atomic = parseUnits(b.confirmedBalance, USDC_DECIMALS$1);
17471
+ available.set(b.chain, (available.get(b.chain) ?? 0n) + atomic);
17472
+ }
17473
+ return available;
17474
+ }
17475
+ /**
17476
+ * Detect source chains whose required draw (value + maxFee across their
17477
+ * intents) exceeds the confirmed balance — the condition the Gateway API
17478
+ * rejects with `BALANCE_INSUFFICIENT_TOKEN` at `/v1/transfer`.
17479
+ *
17480
+ * Both sides are summed per chain (see {@link sumRequiredPerChain} and
17481
+ * {@link computeAvailablePerChain}), so detection is exact for the common
17482
+ * single-depositor-per-chain wallet. When multiple depositors hold USDC on the
17483
+ * same chain, a chain-level surplus can mask a per-depositor shortfall; the
17484
+ * API's own per-depositor `9001` remains the backstop in that case.
17485
+ */ function findOverdrawnChains(estimatedIntents, allocations, chainBalances) {
17486
+ const required = sumRequiredPerChain(estimatedIntents, allocations);
17487
+ const available = computeAvailablePerChain(chainBalances);
17488
+ const overdrawn = [];
17489
+ for (const [chain, req] of required){
17490
+ const avail = available.get(chain) ?? 0n;
17491
+ if (req > avail) {
17492
+ overdrawn.push({
17493
+ chain,
17494
+ required: req,
17495
+ available: avail
17496
+ });
17497
+ }
17498
+ }
17499
+ return overdrawn;
17500
+ }
17501
+ /**
17502
+ * Build a descriptive KitError for an auto-allocation gas shortfall that
17503
+ * survives the corrective re-allocation, naming the per-chain gap so the
17504
+ * caller sees the real cause instead of the opaque API 9001 rejection.
17505
+ */ function createAutoAllocationGasError(overdrawn, cause) {
17506
+ 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('; ');
17507
+ return new KitError({
17508
+ ...BalanceError.INSUFFICIENT_GAS,
17509
+ recoverability: 'FATAL',
17510
+ message: `Insufficient USDC to cover the transfer amount plus Gateway gas fees: ${detail}. ` + `Reduce the amount or add USDC on the affected chain(s).`,
17511
+ ...cause === undefined ? {} : {
17512
+ cause: {
17513
+ trace: {
17514
+ cause
17515
+ }
17516
+ }
17517
+ }
17518
+ });
17519
+ }
17520
+ /**
17521
+ * Validate allocations against the network/forwarder rules, call the estimate
17522
+ * API, and return the estimated intents with any forwarding fee.
17523
+ *
17524
+ * @param allocations - Normalised allocations for the estimate.
17525
+ * @param intents - Burn intents to estimate.
17526
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17527
+ * @param useForwarder - Whether the Forwarding Service path is active.
17528
+ * @returns Estimated intents (with real maxFee) and optional forwarding fee.
17529
+ */ async function validateAndEstimate(allocations, intents, destChain, useForwarder) {
17530
+ assertNetworkCompatibility(allocations, destChain);
17531
+ if (useForwarder) {
17532
+ assertForwarderRouteSupport(destChain, allocations);
17533
+ }
17534
+ const apiBaseUrl = getGatewayApiBaseUrl(destChain.isTestnet);
17535
+ const estimateBody = buildEstimateRequestBody(intents);
17536
+ const { entries, forwardingFee } = await fetchEstimate(apiBaseUrl, estimateBody, useForwarder, allocations);
17537
+ const estimatedIntents = parseEstimateResponse(entries, intents);
17538
+ return {
17539
+ estimatedIntents,
17540
+ forwardingFee
17541
+ };
17542
+ }
17543
+ /**
17544
+ * Fold newly-observed per-chain gas into the accumulated overrides, keeping the
17545
+ * higher fee per chain so a chain a later pass reveals is never under-reserved.
17546
+ */ function mergeGasFeeOverrides(base, next) {
17547
+ const merged = new Map(base);
17548
+ for (const [chain, fee] of next){
17549
+ const prev = merged.get(chain);
17550
+ if (prev === undefined || fee > prev) {
17551
+ merged.set(chain, fee);
17552
+ }
17553
+ }
17554
+ return merged;
17555
+ }
17556
+ /**
17557
+ * Maximum corrective re-allocation passes before failing fast. One pass fixes
17558
+ * the common case; a second/third covers a chain that a spill only introduces
17559
+ * after gas is reserved. Bounds the worst case at this many extra estimate
17560
+ * round-trips (only ever reached when the balance genuinely falls short).
17561
+ */ const MAX_CORRECTION_PASSES = 3;
17562
+ /**
17563
+ * Correct an auto-allocation over-draw: reserve the estimate's real per-chain
17564
+ * gas, re-allocate from the same balances, and re-estimate — repeating up to
17565
+ * {@link MAX_CORRECTION_PASSES} times, accumulating the real gas each pass
17566
+ * reveals.
17567
+ *
17568
+ * One pass fixes the common case, where the over-drawn chain was already in the
17569
+ * first estimate. A further pass covers a chain that a spill only introduces
17570
+ * once gas is reserved on the destination: that chain isn't in the first
17571
+ * estimate, so its real gas is unknown until it appears, and its first
17572
+ * re-allocation falls back to the static reserve. Each pass folds the newly
17573
+ * revealed gas into the overrides (see {@link mergeGasFeeOverrides}) so the
17574
+ * next pass reserves it too. Per-chain gas is ~amount-independent, so once
17575
+ * every drawn chain's real gas is known the allocation converges.
17576
+ *
17577
+ * When the shortfall is genuine — the re-allocation can't cover the amount, or
17578
+ * the passes are exhausted while still over-drawn — throws a gas-specific
17579
+ * {@link KitError} instead of submitting a doomed transfer.
17580
+ */ async function correctOverdraw(opts) {
17581
+ let overrides = deriveGasFeeOverrides(opts.estimatedIntents, opts.allocations);
17582
+ let overdrawn = opts.overdrawn;
17583
+ for(let pass = 0; pass < MAX_CORRECTION_PASSES; pass++){
17584
+ let corrected;
17585
+ try {
17586
+ corrected = await buildAutoAllocatedFromBalances(opts.params, opts.destChain, opts.recipientAddress, opts.useForwarder, opts.amountIn, opts.chainBalances, overrides);
17587
+ } catch (err) {
17588
+ // Re-allocating with the real gas reserved can't cover the amount →
17589
+ // surface a gas-specific error instead of the opaque API rejection.
17590
+ if (err instanceof KitError && err.code === BalanceError.INSUFFICIENT_TOKEN.code) {
17591
+ throw createAutoAllocationGasError(overdrawn, err);
17592
+ }
17593
+ throw err;
17594
+ }
17595
+ const { estimatedIntents, forwardingFee } = await validateAndEstimate(corrected.allocations, corrected.intents, opts.destChain, opts.useForwarder);
17596
+ const stillOverdrawn = findOverdrawnChains(estimatedIntents, corrected.allocations, opts.chainBalances);
17597
+ if (stillOverdrawn.length === 0) {
17598
+ return {
17599
+ allocations: corrected.allocations,
17600
+ estimatedIntents,
17601
+ forwardingFee
17602
+ };
17603
+ }
17604
+ overdrawn = stillOverdrawn;
17605
+ overrides = mergeGasFeeOverrides(overrides, deriveGasFeeOverrides(estimatedIntents, corrected.allocations));
17606
+ }
17607
+ throw createAutoAllocationGasError(overdrawn);
17608
+ }
17252
17609
  /**
17253
17610
  * Validate allocations, call the estimate API, and return estimated intents.
17254
17611
  *
@@ -17256,6 +17613,14 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17256
17613
  * path. Handles forwarder route validation, network compatibility, and the
17257
17614
  * estimate API call.
17258
17615
  *
17616
+ * For auto-allocation (`amountIn`), the greedy allocator reserves a static
17617
+ * per-chain gas fee that can undershoot the API's real fee, draining a source
17618
+ * (typically the destination chain) below `value + maxFee` and triggering a
17619
+ * `BALANCE_INSUFFICIENT_TOKEN` rejection. When the first estimate reveals such
17620
+ * an over-draw, a bounded corrective re-allocation reserves the real gas and
17621
+ * re-estimates until it converges or fails fast (see {@link correctOverdraw}).
17622
+ * Explicit allocations are user-authoritative and never re-allocated.
17623
+ *
17259
17624
  * @param params - Spend parameters.
17260
17625
  * @param destChain - Resolved destination chain with Gateway v1 config.
17261
17626
  * @param recipientAddress - Resolved recipient address.
@@ -17265,15 +17630,24 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17265
17630
  if (useForwarder) {
17266
17631
  assertForwarderRouteSupport(destChain);
17267
17632
  }
17268
- const { allocations, intents } = await resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder);
17269
- assertNetworkCompatibility(allocations, destChain);
17270
- if (useForwarder) {
17271
- assertForwarderRouteSupport(destChain, allocations);
17633
+ const { allocations, intents, chainBalances } = await resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder);
17634
+ const { estimatedIntents, forwardingFee } = await validateAndEstimate(allocations, intents, destChain, useForwarder);
17635
+ if (params.amountIn && chainBalances) {
17636
+ const overdrawn = findOverdrawnChains(estimatedIntents, allocations, chainBalances);
17637
+ if (overdrawn.length > 0) {
17638
+ return correctOverdraw({
17639
+ params,
17640
+ destChain,
17641
+ recipientAddress,
17642
+ useForwarder,
17643
+ amountIn: params.amountIn,
17644
+ chainBalances,
17645
+ estimatedIntents,
17646
+ allocations,
17647
+ overdrawn
17648
+ });
17649
+ }
17272
17650
  }
17273
- const apiBaseUrl = getGatewayApiBaseUrl(destChain.isTestnet);
17274
- const estimateBody = buildEstimateRequestBody(intents);
17275
- const { entries, forwardingFee } = await fetchEstimate(apiBaseUrl, estimateBody, useForwarder, allocations);
17276
- const estimatedIntents = parseEstimateResponse(entries, intents);
17277
17651
  return {
17278
17652
  allocations,
17279
17653
  estimatedIntents,
@@ -18181,8 +18555,10 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18181
18555
  *
18182
18556
  * - `computeFee` — required function that receives resolved spend params
18183
18557
  * and returns a fee as a string (or `Promise<string>`).
18184
- * - `resolveFeeRecipientAddress` — required function that returns a
18185
- * recipient address as a string (or `Promise<string>`).
18558
+ * - `resolveFeeRecipientAddress` — optional function that returns a
18559
+ * recipient address as a string (or `Promise<string>`). Omit it when
18560
+ * using `setFeeRecipients()`'s declarative map instead — a policy
18561
+ * with neither throws at spend time.
18186
18562
  *
18187
18563
  * @example
18188
18564
  * ```ts
@@ -18195,7 +18571,7 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18195
18571
  * ```
18196
18572
  */ const customFeePolicySchema = zod.z.object({
18197
18573
  computeFee: zod.z.function().returns(zod.z.string().or(zod.z.promise(zod.z.string()))),
18198
- resolveFeeRecipientAddress: zod.z.function().returns(zod.z.string().or(zod.z.promise(zod.z.string())))
18574
+ resolveFeeRecipientAddress: zod.z.function().returns(zod.z.string().or(zod.z.promise(zod.z.string()))).optional()
18199
18575
  }).strict();
18200
18576
  /**
18201
18577
  * Assert that the provided value conforms to {@link CustomFeePolicy}.
@@ -18217,6 +18593,71 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18217
18593
  validateWithStateTracking(config, customFeePolicySchema, 'UnifiedBalanceKit custom fee policy', assertCustomFeePolicySymbol);
18218
18594
  }
18219
18595
 
18596
+ const assertFeeRecipientsConfigSymbol = Symbol('assertFeeRecipientsConfig');
18597
+ /**
18598
+ * Schema for validating {@link FeeRecipientsConfig}.
18599
+ *
18600
+ * Requires at least one of `evm`/`solana`, non-empty string values for
18601
+ * whichever keys are present, and — mirroring the `depositAccount`
18602
+ * validation in `deposit/validate/assertions` — an address format that
18603
+ * matches the given chain type (EVM hex vs Solana base58).
18604
+ *
18605
+ * @example
18606
+ * ```ts
18607
+ * const config = {
18608
+ * evm: '0x1234567890123456789012345678901234567890',
18609
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
18610
+ * }
18611
+ * const result = feeRecipientsConfigSchema.safeParse(config)
18612
+ * // result.success === true
18613
+ * ```
18614
+ */ const feeRecipientsConfigSchema = zod.z.object({
18615
+ evm: zod.z.string().min(1, 'Fee recipient address is required.').optional(),
18616
+ solana: zod.z.string().min(1, 'Fee recipient address is required.').optional()
18617
+ }).strict().refine((config)=>Object.keys(config).length > 0, {
18618
+ message: 'At least one fee recipient (evm or solana) is required.'
18619
+ }).superRefine((config, ctx)=>{
18620
+ for (const type of Object.keys(config)){
18621
+ const address = config[type];
18622
+ if (address == null) continue;
18623
+ // `{ name: type, type }` is a placeholder chain identifier — only
18624
+ // `.type` is checked by these two helpers today, `.name` is unused.
18625
+ // No real ChainDefinition exists here, since validation runs before
18626
+ // a destination chain is resolved.
18627
+ if (!isValidAddressForChain(address, {
18628
+ name: type,
18629
+ type
18630
+ })) {
18631
+ const { expectedAddressFormat } = extractChainInfo({
18632
+ name: type,
18633
+ type
18634
+ });
18635
+ ctx.addIssue({
18636
+ code: zod.z.ZodIssueCode.custom,
18637
+ path: [
18638
+ type
18639
+ ],
18640
+ message: `Invalid ${type} address "${address}". Expected ${expectedAddressFormat}.`
18641
+ });
18642
+ }
18643
+ }
18644
+ });
18645
+ /**
18646
+ * Assert that the provided value conforms to {@link FeeRecipientsConfig}.
18647
+ *
18648
+ * Throws a validation error with annotated paths if the configuration is
18649
+ * malformed.
18650
+ *
18651
+ * @param config - The fee recipients map to validate.
18652
+ *
18653
+ * @example
18654
+ * ```ts
18655
+ * assertFeeRecipientsConfig({ evm: '0x1234567890123456789012345678901234567890' })
18656
+ * ```
18657
+ */ function assertFeeRecipientsConfig(config) {
18658
+ validateWithStateTracking(config, feeRecipientsConfigSchema, 'UnifiedBalanceKit fee recipients config', assertFeeRecipientsConfigSymbol);
18659
+ }
18660
+
18220
18661
  function sameChain(a, b) {
18221
18662
  return a.chain !== undefined && a.chain === b.chain;
18222
18663
  }
@@ -19197,6 +19638,105 @@ function assertSourceAccountAddresses(from) {
19197
19638
  config: params.config
19198
19639
  };
19199
19640
  }
19641
+ /**
19642
+ * Tracks, per {@link CustomFeePolicy} instance, which chain types have
19643
+ * already triggered the "falling back to resolveFeeRecipientAddress"
19644
+ * warning, so repeated `spend()`/`estimateSpend()` calls (e.g. live
19645
+ * quoting) warn once per (policy, chain type) pair rather than on every
19646
+ * call.
19647
+ */ const warnedFeeRecipientFallbacks = new WeakMap();
19648
+ /**
19649
+ * Invoke `resolveFeeRecipientAddress` and validate its return value has a
19650
+ * plausible address format for `destChain`, the same check
19651
+ * `setFeeRecipients()` already applies at config time. Unlike the map,
19652
+ * the callback's return value can't be validated ahead of time, so it's
19653
+ * checked here instead — a malformed value throws immediately rather
19654
+ * than silently becoming the fee recipient.
19655
+ *
19656
+ * @internal
19657
+ */ async function resolveFeeRecipientFromCallback(callback, destChain, params) {
19658
+ const address = await callback(destChain, params);
19659
+ if (!isValidAddressForChain(address, destChain)) {
19660
+ throw new KitError({
19661
+ ...InputError.VALIDATION_FAILED,
19662
+ recoverability: 'FATAL',
19663
+ message: `resolveFeeRecipientAddress returned an invalid address ` + `"${address}" for chain type "${destChain.type}" ` + `(resolved destination: ${destChain.name}).`
19664
+ });
19665
+ }
19666
+ return address;
19667
+ }
19668
+ /**
19669
+ * Resolve the single fee recipient address for a spend.
19670
+ *
19671
+ * Every fee burn intent in a spend mints to the same destination
19672
+ * chain regardless of which source chain(s) funded it, so exactly one
19673
+ * recipient address — valid on `destChain` — is ever needed.
19674
+ *
19675
+ * `feeRecipients` (set via `setFeeRecipients`) takes priority over the
19676
+ * policy's `resolveFeeRecipientAddress` callback for any chain type it
19677
+ * has an entry for, since it's a direct lookup and doesn't require
19678
+ * invoking developer code. For a chain type `feeRecipients` doesn't
19679
+ * cover, it falls back to `resolveFeeRecipientAddress` if one is
19680
+ * configured — a warning is logged once per (policy, chain type) pair
19681
+ * so the fallback isn't a silent surprise, without spamming repeated
19682
+ * `estimateSpend()` calls used for live quoting. Throws if neither
19683
+ * resolves `destChain`'s type, or if `resolveFeeRecipientAddress`
19684
+ * resolves it to a malformed address (see
19685
+ * {@link resolveFeeRecipientFromCallback}).
19686
+ *
19687
+ * @internal
19688
+ */ async function resolveFeeRecipient(destChain, policy, feeRecipients, params) {
19689
+ if (feeRecipients) {
19690
+ // `destChain.type` is `@core/chains`' broader `ChainType` union;
19691
+ // `FeeRecipientChainType` is the narrower subset this map supports
19692
+ // today. A type not present as a key simply has no configured
19693
+ // recipient, which is handled below.
19694
+ const type = destChain.type;
19695
+ const recipientAddress = feeRecipients[type];
19696
+ if (recipientAddress) {
19697
+ return recipientAddress;
19698
+ }
19699
+ if (policy.resolveFeeRecipientAddress) {
19700
+ const warnedTypes = warnedFeeRecipientFallbacks.get(policy);
19701
+ if (!warnedTypes?.has(type)) {
19702
+ warnedFeeRecipientFallbacks.set(policy, (warnedTypes ?? new Set()).add(type));
19703
+ 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.`);
19704
+ }
19705
+ return resolveFeeRecipientFromCallback(policy.resolveFeeRecipientAddress, destChain, params);
19706
+ }
19707
+ throw new KitError({
19708
+ ...InputError.VALIDATION_FAILED,
19709
+ recoverability: 'FATAL',
19710
+ 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.`
19711
+ });
19712
+ }
19713
+ if (!policy.resolveFeeRecipientAddress) {
19714
+ throw new KitError({
19715
+ ...InputError.VALIDATION_FAILED,
19716
+ recoverability: 'FATAL',
19717
+ message: 'No fee recipient configured — call setFeeRecipients() or provide ' + 'resolveFeeRecipientAddress on the custom fee policy.'
19718
+ });
19719
+ }
19720
+ return resolveFeeRecipientFromCallback(policy.resolveFeeRecipientAddress, destChain, params);
19721
+ }
19722
+ /**
19723
+ * Guard against a common misconfiguration: a developer sets the
19724
+ * declarative `feeRecipients` map expecting it alone to drive fee
19725
+ * collection, but no fee is ever charged without a `computeFee` from
19726
+ * `customFeePolicy` to determine the amount. Without this check that
19727
+ * misconfiguration fails silently — no fee is charged and no error is
19728
+ * raised.
19729
+ *
19730
+ * @internal
19731
+ */ function assertFeeRecipientsHasPolicy(feeRecipients) {
19732
+ if (feeRecipients) {
19733
+ throw new KitError({
19734
+ ...InputError.VALIDATION_FAILED,
19735
+ recoverability: 'FATAL',
19736
+ 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.'
19737
+ });
19738
+ }
19739
+ }
19200
19740
  /**
19201
19741
  * Apply a {@link CustomFeePolicy} to an adapter-only spend.
19202
19742
  *
@@ -19205,15 +19745,20 @@ function assertSourceAccountAddresses(from) {
19205
19745
  * `config.customFee` so the provider sees it.
19206
19746
  *
19207
19747
  * @internal
19208
- */ async function mergeCustomFeePolicyForAdapterOnly(params, policy) {
19209
- if (params.config?.customFee || !policy) {
19748
+ */ async function mergeCustomFeePolicyForAdapterOnly(params, policy, feeRecipients) {
19749
+ if (params.config?.customFee) {
19750
+ return params;
19751
+ }
19752
+ if (!policy) {
19753
+ assertFeeRecipientsHasPolicy(feeRecipients);
19210
19754
  return params;
19211
19755
  }
19212
19756
  const destChain = resolveChainIdentifier(params.to.chain);
19213
- const [feeValue, recipientAddress] = await Promise.all([
19214
- policy.computeFee(params),
19215
- policy.resolveFeeRecipientAddress(destChain, params)
19216
- ]);
19757
+ // Resolve the recipient before computing the fee: a KitError here
19758
+ // (missing/unresolvable recipient) shouldn't be preceded by an
19759
+ // otherwise-wasted computeFee call, which may be a network request.
19760
+ const recipientAddress = await resolveFeeRecipient(destChain, policy, feeRecipients, params);
19761
+ const feeValue = await policy.computeFee(params);
19217
19762
  return {
19218
19763
  ...params,
19219
19764
  config: {
@@ -19243,18 +19788,35 @@ function assertSourceAccountAddresses(from) {
19243
19788
  });
19244
19789
  }
19245
19790
  }
19246
- async function mergeCustomFeeConfig(resolved, policy) {
19247
- if (resolved.config?.customFee || !policy) {
19791
+ async function mergeCustomFeeConfig(resolved, policy, feeRecipients) {
19792
+ if (resolved.config?.customFee) {
19248
19793
  return resolved;
19249
19794
  }
19250
- const firstSourceChain = resolved.from[0]?.allocations[0]?.chain;
19251
- if (!firstSourceChain) {
19795
+ if (!policy) {
19796
+ assertFeeRecipientsHasPolicy(feeRecipients);
19252
19797
  return resolved;
19253
19798
  }
19254
- const [feeValue, recipientAddress] = await Promise.all([
19255
- policy.computeFee(resolved),
19256
- policy.resolveFeeRecipientAddress(firstSourceChain, resolved)
19257
- ]);
19799
+ // Skip fee resolution when there's no source chain to spend from at
19800
+ // all. This state can't arise from validated input today — the
19801
+ // caller re-checks and throws "No source chain found" right after
19802
+ // this returns — but skipping here isn't dead code: verified that
19803
+ // removing it lets a degenerate zero-allocation resolved value reach
19804
+ // computeFee/assertDeveloperFeeWithinBounds first, which throws a
19805
+ // misleading "Developer fee must be less than the total spend
19806
+ // amount" (0 >= 0 total allocation) instead of the correct "No
19807
+ // source chain found" error — or, for a real developer computeFee
19808
+ // that assumes a non-empty allocation, an uncaught raw exception
19809
+ // instead of any KitError at all. This guard exists to guarantee the
19810
+ // caller's clear error is what actually surfaces, not for
19811
+ // correctness.
19812
+ if (collectSourceChains(resolved).length === 0) {
19813
+ return resolved;
19814
+ }
19815
+ // Resolve the recipient before computing the fee: a KitError here
19816
+ // (missing/unresolvable recipient) shouldn't be preceded by an
19817
+ // otherwise-wasted computeFee call, which may be a network request.
19818
+ const recipientAddress = await resolveFeeRecipient(resolved.to.chain, policy, feeRecipients, resolved);
19819
+ const feeValue = await policy.computeFee(resolved);
19258
19820
  return {
19259
19821
  ...resolved,
19260
19822
  config: {
@@ -19310,14 +19872,14 @@ async function mergeCustomFeeConfig(resolved, policy) {
19310
19872
  }
19311
19873
  const destChain = resolveChainIdentifier(params.to.chain);
19312
19874
  if (!hasExplicitAllocations(params.from)) {
19313
- const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy);
19875
+ const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy, context.feeRecipients);
19314
19876
  assertDeveloperFeeWithinAmount(merged);
19315
19877
  const provider = findProviderForChain(context, normalizeToken(merged.token), destChain);
19316
19878
  return callSpend(provider, toProviderAdapterOnlyParams(merged));
19317
19879
  }
19318
19880
  const resolved = await resolveSpendParams(params);
19319
19881
  assertSpendNetworkCompatibility(resolved);
19320
- const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy);
19882
+ const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy, context.feeRecipients);
19321
19883
  assertDeveloperFeeWithinBounds(withFee);
19322
19884
  const sourceChains = collectSourceChains(withFee);
19323
19885
  if (sourceChains.length === 0) {
@@ -19357,14 +19919,14 @@ async function mergeCustomFeeConfig(resolved, policy) {
19357
19919
  assertSpendParams(params);
19358
19920
  const destChain = resolveChainIdentifier(params.to.chain);
19359
19921
  if (!hasExplicitAllocations(params.from)) {
19360
- const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy);
19922
+ const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy, context.feeRecipients);
19361
19923
  assertDeveloperFeeWithinAmount(merged);
19362
19924
  const provider = findProviderForChain(context, normalizeToken(merged.token), destChain);
19363
19925
  return provider.estimateSpend(toProviderAdapterOnlyParams(merged));
19364
19926
  }
19365
19927
  const resolved = await resolveSpendParams(params);
19366
19928
  assertSpendNetworkCompatibility(resolved);
19367
- const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy);
19929
+ const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy, context.feeRecipients);
19368
19930
  assertDeveloperFeeWithinBounds(withFee);
19369
19931
  const sourceChains = collectSourceChains(withFee);
19370
19932
  if (sourceChains.length === 0) {
@@ -19901,7 +20463,11 @@ const removeFundParamsSchema = zod.z.object({
19901
20463
  // Remove Fund Operations
19902
20464
  // ---------------------------------------------------------------------------
19903
20465
  /**
19904
- * Kick off a delayed fund removal from an account.
20466
+ * Kick off a delayed recovery fund removal from an account.
20467
+ *
20468
+ * Use `initiateRemoveFund` only as a trustless fallback when the normal spend
20469
+ * flow is unavailable. For day-to-day movement out of a Unified Balance, use
20470
+ * `spend`.
19905
20471
  *
19906
20472
  * Validates `from` and `amount`, resolves the chain and token via
19907
20473
  * {@link resolveRemoveFundParams}, selects the matching provider, then calls
@@ -19938,7 +20504,10 @@ const removeFundParamsSchema = zod.z.object({
19938
20504
  return provider.initiateRemoveFund(resolved);
19939
20505
  }
19940
20506
  /**
19941
- * Complete a fund removal once the 7-day activation period has passed.
20507
+ * Complete a recovery fund removal once the 7-day withdrawal delay has passed.
20508
+ *
20509
+ * Use `removeFund` only as a trustless fallback when the normal spend flow is
20510
+ * unavailable. For day-to-day movement out of a Unified Balance, use `spend`.
19942
20511
  *
19943
20512
  * Validates `from`, resolves the chain and token via
19944
20513
  * {@link resolveRemoveFundParams}, selects the matching provider, then calls
@@ -20027,13 +20596,18 @@ const removeFundParamsSchema = zod.z.object({
20027
20596
  /** SDK name used in telemetry payloads. */ const SDK_NAME = resolveKitSdkName(pkg.name);
20028
20597
  /**
20029
20598
  * A high-level class-based interface for cross-chain USDC deposits,
20030
- * spending, balance queries, delegation management, and withdrawals.
20599
+ * spending, balance queries, delegation management, and recovery fund removals.
20031
20600
  *
20032
20601
  * UnifiedBalanceKit provides a familiar class-based API for developers who
20033
20602
  * prefer traditional object-oriented patterns. The class maintains an
20034
20603
  * internal context and provides methods that delegate to the standalone
20035
20604
  * operation functions exported by this package.
20036
20605
  *
20606
+ * Use `spend` for normal movement out of a Unified Balance. `removeFund` is a
20607
+ * trustless recovery path for situations where the normal spend flow is
20608
+ * unavailable, and it requires a 7-day withdrawal delay before funds can be
20609
+ * removed.
20610
+ *
20037
20611
  * @remarks
20038
20612
  * For functional usage, import and use the operations directly:
20039
20613
  * ```typescript
@@ -20254,7 +20828,11 @@ const removeFundParamsSchema = zod.z.object({
20254
20828
  });
20255
20829
  }
20256
20830
  /**
20257
- * Kick off a delayed fund removal from an account.
20831
+ * Kick off a delayed recovery fund removal from an account.
20832
+ *
20833
+ * Use this only as a trustless fallback when the normal spend flow is
20834
+ * unavailable. For day-to-day movement out of a Unified Balance, use
20835
+ * `spend`.
20258
20836
  *
20259
20837
  * @param params - The account owner's adapter context, amount, and
20260
20838
  * optional token type.
@@ -20268,7 +20846,12 @@ const removeFundParamsSchema = zod.z.object({
20268
20846
  });
20269
20847
  }
20270
20848
  /**
20271
- * Complete a fund removal once the activation period has passed.
20849
+ * Complete a recovery fund removal once the 7-day withdrawal delay has
20850
+ * passed.
20851
+ *
20852
+ * Use this only as a trustless fallback when the normal spend flow is
20853
+ * unavailable. For day-to-day movement out of a Unified Balance, use
20854
+ * `spend`.
20272
20855
  *
20273
20856
  * @param params - The account owner context matching the original
20274
20857
  * fund removal initiation.
@@ -20339,6 +20922,46 @@ const removeFundParamsSchema = zod.z.object({
20339
20922
  */ removeCustomFeePolicy() {
20340
20923
  delete this.context.customFeePolicy;
20341
20924
  }
20925
+ /**
20926
+ * Set a declarative fee recipient map, keyed by chain type. Once set,
20927
+ * `spend()`/`estimateSpend()` resolve the fee recipient by looking up
20928
+ * the spend's destination chain type in this map — taking priority
20929
+ * over `customFeePolicy`'s `resolveFeeRecipientAddress` callback.
20930
+ *
20931
+ * @remarks
20932
+ * This only controls which address a fee is sent to — it does not by
20933
+ * itself cause any fee to be charged. You still need
20934
+ * {@link UnifiedBalanceKit.setCustomFeePolicy}'s `computeFee` to
20935
+ * determine the fee amount; calling `setFeeRecipients` without ever
20936
+ * calling `setCustomFeePolicy` throws at spend time (there is no
20937
+ * `computeFee` to determine an amount).
20938
+ *
20939
+ * @param config - Fee recipient addresses keyed by chain type (e.g.
20940
+ * `{ evm: '0x...', solana: 'Sol...' }`). Provide entries for every
20941
+ * chain type you expect to spend to; spending to a chain type with
20942
+ * no matching entry throws before any fee collection is attempted.
20943
+ *
20944
+ * @example
20945
+ * ```typescript
20946
+ * kit.setFeeRecipients({
20947
+ * evm: '0x1234567890123456789012345678901234567890',
20948
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
20949
+ * })
20950
+ * ```
20951
+ */ setFeeRecipients(config) {
20952
+ assertFeeRecipientsConfig(config);
20953
+ this.context.feeRecipients = config;
20954
+ }
20955
+ /**
20956
+ * Remove the declarative fee recipient map for the kit.
20957
+ *
20958
+ * @example
20959
+ * ```typescript
20960
+ * kit.removeFeeRecipients()
20961
+ * ```
20962
+ */ removeFeeRecipients() {
20963
+ delete this.context.feeRecipients;
20964
+ }
20342
20965
  }
20343
20966
 
20344
20967
  // Auto-register this kit for user agent tracking
@@ -20355,6 +20978,11 @@ registerKit(`${pkg.name}/${pkg.version}`);
20355
20978
  * Internally holds a persistent {@link UnifiedBalanceKit} instance so that
20356
20979
  * event dispatchers and custom fee policies are preserved across calls.
20357
20980
  *
20981
+ * Use {@link AppKitUnifiedBalance.spend} for normal movement out of a Unified
20982
+ * Balance. {@link AppKitUnifiedBalance.removeFund} is a trustless recovery path
20983
+ * for situations where the normal spend flow is unavailable, and it requires a
20984
+ * 7-day withdrawal delay after {@link AppKitUnifiedBalance.initiateRemoveFund}.
20985
+ *
20358
20986
  * @example
20359
20987
  * ```typescript
20360
20988
  * import { AppKit } from '@circle-fin/app-kit'
@@ -20566,7 +21194,12 @@ registerKit(`${pkg.name}/${pkg.version}`);
20566
21194
  return this.kit.removeDelegate(params);
20567
21195
  }
20568
21196
  /**
20569
- * Kick off a delayed fund removal from an account.
21197
+ * Initiate a trustless recovery removal from an account.
21198
+ *
21199
+ * Use `spend` for normal movement out of a Unified Balance. `removeFund` is a
21200
+ * recovery path for situations where the normal spend flow is unavailable.
21201
+ * Calling this method starts the 7-day withdrawal delay before the removal can
21202
+ * be completed.
20570
21203
  *
20571
21204
  * @param params - The account owner's adapter context, amount, and token.
20572
21205
  * @returns Promise resolving to the initiation details.
@@ -20585,11 +21218,16 @@ registerKit(`${pkg.name}/${pkg.version}`);
20585
21218
  return this.kit.initiateRemoveFund(params);
20586
21219
  }
20587
21220
  /**
20588
- * Complete a fund removal once the activation period has passed.
21221
+ * Complete a trustless recovery removal after the withdrawal delay.
21222
+ *
21223
+ * Use `spend` for normal movement out of a Unified Balance. `removeFund` is a
21224
+ * recovery path for situations where the normal spend flow is unavailable.
21225
+ * Both EVM and Solana removals require a 7-day withdrawal delay after
21226
+ * `initiateRemoveFund` before funds can be removed.
20589
21227
  *
20590
21228
  * @param params - The account owner context matching the original initiation.
20591
21229
  * @returns Promise resolving to the fund removal details.
20592
- * @throws {KitError} If the activation period has not elapsed or the
21230
+ * @throws {KitError} If the withdrawal delay has not elapsed or the
20593
21231
  * on-chain transaction fails.
20594
21232
  *
20595
21233
  * @example
@@ -20665,6 +21303,45 @@ registerKit(`${pkg.name}/${pkg.version}`);
20665
21303
  */ removeCustomFeePolicy() {
20666
21304
  this.kit.removeCustomFeePolicy();
20667
21305
  }
21306
+ /**
21307
+ * Set a declarative fee recipient map, keyed by chain type.
21308
+ *
21309
+ * Once set, `spend()`/`estimateSpend()` resolve the fee recipient by
21310
+ * looking up the spend's destination chain type in this map — taking
21311
+ * priority over `customFeePolicy`'s `resolveFeeRecipientAddress`
21312
+ * callback.
21313
+ *
21314
+ * @remarks
21315
+ * This only controls which address a fee is sent to — it does not by
21316
+ * itself cause any fee to be charged. You still need
21317
+ * `setCustomFeePolicy`'s `computeFee` to determine the fee amount;
21318
+ * calling `setFeeRecipients` without ever calling `setCustomFeePolicy`
21319
+ * throws at spend time (there is no `computeFee` to determine an
21320
+ * amount).
21321
+ *
21322
+ * @param config - Fee recipient addresses keyed by chain type (e.g.
21323
+ * `{ evm: '0x...', solana: 'Sol...' }`).
21324
+ *
21325
+ * @example
21326
+ * ```typescript
21327
+ * kit.unifiedBalance.setFeeRecipients({
21328
+ * evm: '0x1234567890123456789012345678901234567890',
21329
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
21330
+ * })
21331
+ * ```
21332
+ */ setFeeRecipients(config) {
21333
+ this.kit.setFeeRecipients(config);
21334
+ }
21335
+ /**
21336
+ * Remove the declarative fee recipient map.
21337
+ *
21338
+ * @example
21339
+ * ```typescript
21340
+ * kit.unifiedBalance.removeFeeRecipients()
21341
+ * ```
21342
+ */ removeFeeRecipients() {
21343
+ this.kit.removeFeeRecipients();
21344
+ }
20668
21345
  }
20669
21346
 
20670
21347
  exports.AppKitUnifiedBalance = AppKitUnifiedBalance;