@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.
@@ -16,6 +16,17 @@
16
16
  * limitations under the License.
17
17
  */
18
18
 
19
+ // Buffer polyfill setup - executes before any other code
20
+ // Ensures globalThis.Buffer is available for Solana libraries
21
+ import { Buffer } from 'buffer';
22
+ if (typeof globalThis !== 'undefined' && typeof globalThis.Buffer === 'undefined') {
23
+ globalThis.Buffer = Buffer;
24
+ }
25
+ if (typeof window !== 'undefined' && typeof window.Buffer === 'undefined') {
26
+ window.Buffer = Buffer;
27
+ }
28
+
29
+
19
30
  import { z } from 'zod';
20
31
  import { PublicKey } from '@solana/web3.js';
21
32
  import 'bn.js';
@@ -41,6 +52,27 @@ import { getAddress } from '@ethersproject/address';
41
52
  * }
42
53
  * ```
43
54
  */ const isNodeEnvironment = ()=>typeof process !== 'undefined' && typeof process.versions === 'object' && typeof process.versions.node === 'string';
55
+ /**
56
+ * Return the SDK User-Agent request header only when running in Node.js.
57
+ *
58
+ * Browsers forbid manually setting `User-Agent`, and a custom fallback header
59
+ * can trigger CORS preflight. Non-Node server runtimes also omit this optional
60
+ * attribution header because they cannot set it reliably.
61
+ *
62
+ * @returns A User-Agent header in Node.js, or an empty object otherwise.
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * import { getNodeUserAgentHeader } from '@core/utils'
67
+ *
68
+ * const headers = {
69
+ * 'Content-Type': 'application/json',
70
+ * ...getNodeUserAgentHeader(),
71
+ * }
72
+ * ```
73
+ */ const getNodeUserAgentHeader = ()=>isNodeEnvironment() ? {
74
+ 'User-Agent': getUserAgent()
75
+ } : {};
44
76
  /**
45
77
  * Detect the runtime environment and return a shortened identifier.
46
78
  *
@@ -2868,7 +2900,10 @@ var EarnChain;
2868
2900
  contracts: {
2869
2901
  v1: {
2870
2902
  wallet: GATEWAY_WALLET_EVM_TESTNET,
2871
- minter: GATEWAY_MINTER_EVM_TESTNET
2903
+ minter: GATEWAY_MINTER_EVM_TESTNET,
2904
+ // DepositForHandler the GenericExecutor calls to run a fast cross-chain
2905
+ // deposit into the GatewayWallet above.
2906
+ depositForHandler: '0xD05E7D2E7d30b92c5F17d7d0fC575fce231F1A48'
2872
2907
  }
2873
2908
  },
2874
2909
  forwarderSupported: {
@@ -5952,7 +5987,10 @@ var Chains = /*#__PURE__*/Object.freeze({
5952
5987
  minter: z.string({
5953
5988
  required_error: 'Gateway minter address is required. Please provide a valid contract address.',
5954
5989
  invalid_type_error: 'Gateway minter address must be a string.'
5955
- }).min(1, 'Gateway minter address cannot be empty.')
5990
+ }).min(1, 'Gateway minter address cannot be empty.'),
5991
+ depositForHandler: z.string({
5992
+ invalid_type_error: 'Gateway depositForHandler address must be a string.'
5993
+ }).min(1, 'Gateway depositForHandler address cannot be empty.').optional()
5956
5994
  }).strict() // Reject any additional properties not defined in the schema
5957
5995
  ;
5958
5996
  /**
@@ -7074,13 +7112,12 @@ const swapTokenEnumSchema = z.enum([
7074
7112
  headers: {
7075
7113
  ...DEFAULT_CONFIG.headers,
7076
7114
  ...config.headers ?? {},
7077
- // In browser environments, directly setting the 'User-Agent' or similar headers is restricted and may be ignored or cause errors.
7078
- // This is why we use the 'X-User-Agent' header instead.
7079
- ...typeof window === 'undefined' ? {
7080
- 'User-Agent': getUserAgent()
7081
- } : {
7082
- 'X-User-Agent': getUserAgent()
7083
- }
7115
+ // Browsers forbid setting a user-agent request header, and the custom
7116
+ // fallback header the SDK used instead trips CORS preflight against the
7117
+ // Circle APIs (it isn't in their `Access-Control-Allow-Headers`),
7118
+ // blocking the request. So send the SDK user agent only in Node;
7119
+ // browsers omit it entirely.
7120
+ ...getNodeUserAgentHeader()
7084
7121
  }
7085
7122
  };
7086
7123
  let lastError;
@@ -8352,6 +8389,7 @@ function parseOrThrow(value, schema, context) {
8352
8389
  if (payload.tokenIn !== undefined) safe['tokenIn'] = payload.tokenIn;
8353
8390
  if (payload.tokenOut !== undefined) safe['tokenOut'] = payload.tokenOut;
8354
8391
  if (payload.txHash !== undefined) safe['txHash'] = payload.txHash;
8392
+ if (payload.correlationId !== undefined) safe['correlationId'] = payload.correlationId;
8355
8393
  if (payload.errorDetails !== undefined) {
8356
8394
  const errorDetails = {
8357
8395
  ...payload.errorDetails.errorCode !== undefined && {
@@ -8422,18 +8460,15 @@ function parseOrThrow(value, schema, context) {
8422
8460
  timeoutHandle.unref();
8423
8461
  }
8424
8462
  try {
8425
- const isNode = isNodeEnvironment();
8426
- const userAgent = getUserAgent();
8427
8463
  await fetch(getLogsUrl(), {
8428
8464
  method: 'POST',
8429
8465
  headers: {
8430
8466
  'Content-Type': 'application/json',
8431
- // Browser restricts setting User-Agent; use X-User-Agent instead.
8432
- ...isNode ? {
8433
- 'User-Agent': userAgent
8434
- } : {
8435
- 'X-User-Agent': userAgent
8436
- }
8467
+ // Browsers forbid setting a user-agent request header, and the custom
8468
+ // fallback header the SDK used instead trips CORS preflight (it isn't
8469
+ // in the telemetry endpoint's `Access-Control-Allow-Headers`), so send
8470
+ // it only in Node; browsers omit it entirely.
8471
+ ...getNodeUserAgentHeader()
8437
8472
  },
8438
8473
  body: JSON.stringify(toSafePayload(payload)),
8439
8474
  signal: controller.signal
@@ -8646,7 +8681,7 @@ function parseOrThrow(value, schema, context) {
8646
8681
  // discards the stack trace, nested `cause`, and any custom Error
8647
8682
  // properties — exactly the context an on-call needs when a
8648
8683
  // resolver-closure regression triggers this path.
8649
- console.warn(`[stablecoin-kits telemetry] dropped error event '${eventType}':`, cause);
8684
+ console.warn(`[stablecoin-kits telemetry] dropped event '${eventType}':`, cause);
8650
8685
  } catch {
8651
8686
  // console.warn itself throwing is the user's environment; nothing more we
8652
8687
  // can do without risking the original operation error.
@@ -8662,7 +8697,9 @@ function parseOrThrow(value, schema, context) {
8662
8697
  sdkVersion: config.sdkVersion,
8663
8698
  eventType,
8664
8699
  timestamp: new Date().toISOString(),
8665
- errorDetails,
8700
+ ...errorDetails !== undefined && {
8701
+ errorDetails
8702
+ },
8666
8703
  clientContext: buildClientContext(),
8667
8704
  ...context?.sourceChain != null && {
8668
8705
  sourceChain: context.sourceChain
@@ -8678,6 +8715,9 @@ function parseOrThrow(value, schema, context) {
8678
8715
  },
8679
8716
  ...context?.txHash != null && {
8680
8717
  txHash: context.txHash
8718
+ },
8719
+ ...context?.correlationId != null && {
8720
+ correlationId: context.correlationId
8681
8721
  }
8682
8722
  };
8683
8723
  }
@@ -8739,7 +8779,7 @@ function parseOrThrow(value, schema, context) {
8739
8779
  }
8740
8780
 
8741
8781
  var name = "@circle-fin/unified-balance-kit";
8742
- var version = "1.2.2";
8782
+ var version = "1.3.1";
8743
8783
  var pkg = {
8744
8784
  name: name,
8745
8785
  version: version};
@@ -16375,29 +16415,33 @@ const CIRCLE_BPS_DIVISOR = 10_000n;
16375
16415
  const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16376
16416
  /**
16377
16417
  * Return the estimated Gateway gas fee for a chain in USDC atomic units.
16378
- * Falls back to a conservative 0.1 USDC for unlisted chains.
16418
+ * Prefers an entry in `overrides` (the real per-chain fee derived from a
16419
+ * prior estimate), then the static {@link GAS_FEE_BY_CHAIN} constant, and
16420
+ * finally a conservative 0.1 USDC fallback for unlisted chains.
16379
16421
  *
16380
16422
  * @param chain - The source blockchain.
16423
+ * @param overrides - Optional real per-chain gas fees keyed by chain.
16381
16424
  * @returns Gas fee in USDC atomic units.
16382
- */ function getGasFee(chain) {
16383
- return GAS_FEE_BY_CHAIN.get(chain) ?? DEFAULT_GAS_FEE;
16425
+ */ function getGasFee(chain, overrides) {
16426
+ return overrides?.get(chain) ?? GAS_FEE_BY_CHAIN.get(chain) ?? DEFAULT_GAS_FEE;
16384
16427
  }
16385
16428
  /**
16386
16429
  * Return the estimated forwarder fee for the destination chain
16387
16430
  * (service fee + destination gas fee).
16388
16431
  *
16389
16432
  * @param destinationChain - The mint destination chain.
16433
+ * @param overrides - Optional real per-chain gas fees keyed by chain.
16390
16434
  * @returns Forwarder fee in USDC atomic units.
16391
- */ function getForwarderFee(destinationChain) {
16392
- const destGas = getGasFee(destinationChain);
16435
+ */ function getForwarderFee(destinationChain, overrides) {
16436
+ const destGas = getGasFee(destinationChain, overrides);
16393
16437
  return FORWARDER_SERVICE_FEE + destGas;
16394
16438
  }
16395
16439
  /**
16396
16440
  * Estimate the fixed fees (gas + forwarder) and compute the maximum
16397
16441
  * amount that can be drawn from this chain for a single intent,
16398
16442
  * accounting for the 0.5 bps transfer fee if cross-chain.
16399
- */ function computeMaxDrawable(slot, forwarderFeeRemaining) {
16400
- const gasFee = getGasFee(slot.chain);
16443
+ */ function computeMaxDrawable(slot, forwarderFeeRemaining, overrides) {
16444
+ const gasFee = getGasFee(slot.chain, overrides);
16401
16445
  let fixedFees = gasFee;
16402
16446
  let forwarderFeeUsed = 0n;
16403
16447
  if (forwarderFeeRemaining > 0n) {
@@ -16429,14 +16473,14 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16429
16473
  * buffer), and returns the allocations for this pass.
16430
16474
  *
16431
16475
  * Mutates `slot.remaining` so the next pass sees reduced balances.
16432
- */ function greedyAllocate(slots, amount, destinationChain, useForwarder) {
16476
+ */ function greedyAllocate(slots, amount, destinationChain, useForwarder, overrides) {
16433
16477
  const result = [];
16434
16478
  let remaining = amount;
16435
- let forwarderFeeRemaining = useForwarder ? getForwarderFee(destinationChain) : 0n;
16479
+ let forwarderFeeRemaining = useForwarder ? getForwarderFee(destinationChain, overrides) : 0n;
16436
16480
  if (remaining <= 0n) return result;
16437
16481
  for (const slot of slots){
16438
16482
  if (remaining <= 0n) break;
16439
- const { drawable, gasFee, forwarderFeeUsed } = computeMaxDrawable(slot, forwarderFeeRemaining);
16483
+ const { drawable, gasFee, forwarderFeeUsed } = computeMaxDrawable(slot, forwarderFeeRemaining, overrides);
16440
16484
  if (drawable <= 0n) continue;
16441
16485
  // Greedy: take as much as we can from this chain
16442
16486
  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.
@@ -16535,7 +16579,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16535
16579
  // After this pass, slot.remaining reflects consumed capacity.
16536
16580
  // -----------------------------------------------------------------------
16537
16581
  const transferAmount = parseUnits(ctx.amountIn, USDC_DECIMALS);
16538
- const allocations = greedyAllocate(slots, transferAmount, ctx.destinationChain, ctx.useForwarder);
16582
+ const allocations = greedyAllocate(slots, transferAmount, ctx.destinationChain, ctx.useForwarder, ctx.gasFeeOverrides);
16539
16583
  assertFullyAllocated(allocations, transferAmount, ctx.amountIn);
16540
16584
  // -----------------------------------------------------------------------
16541
16585
  // 4. Phase 2 — Allocate developer fee
@@ -16543,7 +16587,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16543
16587
  // Same-chain first here too — if the destination chain still has
16544
16588
  // capacity, use it (same-chain fee intent = cheapest gas).
16545
16589
  // -----------------------------------------------------------------------
16546
- const developerFeeAllocations = greedyAllocate(slots, devFeeAmount, ctx.destinationChain, false);
16590
+ const developerFeeAllocations = greedyAllocate(slots, devFeeAmount, ctx.destinationChain, false, ctx.gasFeeOverrides);
16547
16591
  if (devFeeAmount > 0n) {
16548
16592
  assertFullyAllocated(developerFeeAllocations, devFeeAmount, formatUnits(devFeeAmount.toString(), USDC_DECIMALS));
16549
16593
  }
@@ -16552,7 +16596,7 @@ const DEFAULT_GAS_FEE = parseUnits('0.1', USDC_DECIMALS);
16552
16596
  // Again same ordering, same shared reduced balances.
16553
16597
  // Same-chain first for the same reason.
16554
16598
  // -----------------------------------------------------------------------
16555
- const circleFeeAllocations = greedyAllocate(slots, circleFeeAmount, ctx.destinationChain, false);
16599
+ const circleFeeAllocations = greedyAllocate(slots, circleFeeAmount, ctx.destinationChain, false, ctx.gasFeeOverrides);
16556
16600
  if (circleFeeAmount > 0n) {
16557
16601
  assertFullyAllocated(circleFeeAllocations, circleFeeAmount, formatUnits(circleFeeAmount.toString(), USDC_DECIMALS));
16558
16602
  }
@@ -16651,10 +16695,12 @@ const BPS_DIVISOR = 100_000n;
16651
16695
  *
16652
16696
  * Unlike `findChainNameByDomain` (which returns the display `name`),
16653
16697
  * this returns `chain.chain` — the enum identifier expected by
16654
- * {@link FeeAllocation}.
16698
+ * {@link FeeAllocation}. Returns `undefined` when no allocation covers the
16699
+ * domain so callers skip the intent rather than bucketing it under a
16700
+ * fabricated sentinel.
16655
16701
  */ function findBlockchainByDomain(domain, allocations) {
16656
16702
  const alloc = allocations.find((a)=>a.chain.gateway.domain === domain);
16657
- return alloc?.chain.chain ?? 'Unknown';
16703
+ return alloc?.chain.chain;
16658
16704
  }
16659
16705
  /**
16660
16706
  * Normalize any address/salt format to lowercase bytes32 hex.
@@ -16778,6 +16824,33 @@ const BPS_DIVISOR = 100_000n;
16778
16824
  };
16779
16825
  });
16780
16826
  }
16827
+ /**
16828
+ * Read an intent's transfer value as a BigInt, tolerating the string form
16829
+ * that can appear on estimate-response specs.
16830
+ */ function intentValue(intent) {
16831
+ const { value } = intent.spec;
16832
+ return typeof value === 'bigint' ? value : safeBigInt(String(value), 'spec.value');
16833
+ }
16834
+ /**
16835
+ * Split a single intent's `maxFee` into its transfer-fee and gas-fee
16836
+ * components.
16837
+ *
16838
+ * `transferFee = value * GATEWAY_TRANSFER_FEE_SCALED_BPS / BPS_DIVISOR`
16839
+ * `gasFee = maxFee - transferFee`
16840
+ *
16841
+ * Same-chain transfers (withdrawals) do not incur a transfer fee, so the
16842
+ * whole `maxFee` is gas. See {@link aggregateFeesByIntent} for the caveats
16843
+ * on re-deriving the split locally.
16844
+ */ function splitIntentFee(intent) {
16845
+ const { maxFee, spec } = intent;
16846
+ const isSameChain = spec.sourceDomain === spec.destinationDomain;
16847
+ const transferFee = isSameChain ? 0n : intentValue(intent) * GATEWAY_TRANSFER_FEE_SCALED_BPS / BPS_DIVISOR;
16848
+ const gasFee = maxFee > transferFee ? maxFee - transferFee : 0n;
16849
+ return {
16850
+ transferFee,
16851
+ gasFee
16852
+ };
16853
+ }
16781
16854
  /**
16782
16855
  * Decompose each intent's `maxFee` into a transfer fee and a gas fee,
16783
16856
  * then aggregate both by source chain.
@@ -16802,7 +16875,6 @@ const BPS_DIVISOR = 100_000n;
16802
16875
  * names from source domains.
16803
16876
  * @returns Per-chain and total transfer/gas fee breakdowns.
16804
16877
  */ function aggregateFeesByIntent(estimatedIntents, allocations) {
16805
- const transferFeeBps = GATEWAY_TRANSFER_FEE_SCALED_BPS;
16806
16878
  const transferFeeByChain = new Map();
16807
16879
  const gasFeeByChain = new Map();
16808
16880
  let totalTransferFee = 0n;
@@ -16810,18 +16882,18 @@ const BPS_DIVISOR = 100_000n;
16810
16882
  for (const intent of estimatedIntents){
16811
16883
  const { maxFee, spec } = intent;
16812
16884
  if (maxFee === 0n) continue;
16813
- const chainName = findBlockchainByDomain(spec.sourceDomain, allocations);
16814
- const value = typeof spec.value === 'bigint' ? spec.value : safeBigInt(String(spec.value), 'spec.value');
16815
- const isSameChain = spec.sourceDomain === spec.destinationDomain;
16816
- const transferFee = isSameChain ? 0n : value * transferFeeBps / BPS_DIVISOR;
16817
- const gasFee = maxFee > transferFee ? maxFee - transferFee : 0n;
16885
+ const { transferFee, gasFee } = splitIntentFee(intent);
16886
+ totalTransferFee += transferFee;
16887
+ totalGasFee += gasFee;
16888
+ // Totals stay complete even if a domain can't be resolved; only the
16889
+ // per-chain breakdown skips it rather than inventing a placeholder chain.
16890
+ const chain = findBlockchainByDomain(spec.sourceDomain, allocations);
16891
+ if (chain === undefined) continue;
16818
16892
  if (transferFee > 0n) {
16819
- totalTransferFee += transferFee;
16820
- transferFeeByChain.set(chainName, (transferFeeByChain.get(chainName) ?? 0n) + transferFee);
16893
+ transferFeeByChain.set(chain, (transferFeeByChain.get(chain) ?? 0n) + transferFee);
16821
16894
  }
16822
16895
  if (gasFee > 0n) {
16823
- totalGasFee += gasFee;
16824
- gasFeeByChain.set(chainName, (gasFeeByChain.get(chainName) ?? 0n) + gasFee);
16896
+ gasFeeByChain.set(chain, (gasFeeByChain.get(chain) ?? 0n) + gasFee);
16825
16897
  }
16826
16898
  }
16827
16899
  return {
@@ -16885,6 +16957,91 @@ const BPS_DIVISOR = 100_000n;
16885
16957
  }
16886
16958
  return fees;
16887
16959
  }
16960
+ /**
16961
+ * Derive the real per-chain Gateway gas fee from estimated intents, keyed by
16962
+ * source {@link Blockchain}.
16963
+ *
16964
+ * The value is the maximum single-intent gas fee observed on each chain
16965
+ * (`maxFee − transferFee`) — the amount `computeAutoAllocation` must reserve
16966
+ * per burn intent on that chain. Gas is (near) amount-independent, so every
16967
+ * intent on a chain pays roughly the same; taking the max is a conservative
16968
+ * choice for the multi-intent-per-chain case.
16969
+ *
16970
+ * Intended for `AutoAllocationContext.gasFeeOverrides` so the corrective
16971
+ * re-allocation pass reserves the API's real fee instead of the static
16972
+ * {@link GAS_FEE_BY_CHAIN} constant.
16973
+ *
16974
+ * @param estimatedIntents - Intents with `maxFee` from {@link parseEstimateResponse}.
16975
+ * @param allocations - Normalised allocations used to resolve chain from source domain.
16976
+ * @returns Per-chain real gas fees in USDC atomic units.
16977
+ *
16978
+ * @example
16979
+ * ```typescript
16980
+ * import type { BurnIntent } from '../createIntent/types'
16981
+ * import type { NormalizedAllocation } from '../allocations'
16982
+ *
16983
+ * declare const estimatedIntents: BurnIntent[]
16984
+ * declare const allocations: NormalizedAllocation[]
16985
+ *
16986
+ * // Real per-chain gas, ready to pass as AutoAllocationContext.gasFeeOverrides
16987
+ * // to re-run computeAutoAllocation with the corrected reserve.
16988
+ * const overrides = deriveGasFeeOverrides(estimatedIntents, allocations)
16989
+ * ```
16990
+ */ function deriveGasFeeOverrides(estimatedIntents, allocations) {
16991
+ const overrides = new Map();
16992
+ for (const intent of estimatedIntents){
16993
+ if (intent.maxFee === 0n) continue;
16994
+ const chain = findBlockchainByDomain(intent.spec.sourceDomain, allocations);
16995
+ if (chain === undefined) continue;
16996
+ const { gasFee } = splitIntentFee(intent);
16997
+ const prev = overrides.get(chain) ?? 0n;
16998
+ if (gasFee > prev) overrides.set(chain, gasFee);
16999
+ }
17000
+ return overrides;
17001
+ }
17002
+ /**
17003
+ * Sum the total balance each source chain must cover, keyed by source
17004
+ * {@link Blockchain}.
17005
+ *
17006
+ * Approximates the Gateway API's balance validation, which rejects a transfer
17007
+ * (`BALANCE_INSUFFICIENT_TOKEN`) when a depositor's confirmed balance on a
17008
+ * source chain is below `sum(intent.value + intent.maxFee)` for that
17009
+ * depositor's intents. This aggregates by chain across all sources, so it is
17010
+ * exact for the common single-depositor-per-chain wallet. When several
17011
+ * depositors hold USDC on the same chain, the chain-level sum can mask a
17012
+ * per-depositor shortfall (or a surplus on one depositor can hide it); the
17013
+ * API's own per-depositor `9001` remains the backstop for that case. Scope the
17014
+ * comparison per (depositor, chain) if that multi-depositor case must be caught
17015
+ * pre-submit.
17016
+ *
17017
+ * @param estimatedIntents - Intents with `maxFee` from {@link parseEstimateResponse}.
17018
+ * @param allocations - Normalised allocations used to resolve chain from source domain.
17019
+ * @returns Per-chain required amount (transfer value + fees) in USDC atomic units.
17020
+ *
17021
+ * @example
17022
+ * ```typescript
17023
+ * import { Blockchain } from '@core/chains'
17024
+ * import type { BurnIntent } from '../createIntent/types'
17025
+ * import type { NormalizedAllocation } from '../allocations'
17026
+ *
17027
+ * declare const estimatedIntents: BurnIntent[]
17028
+ * declare const allocations: NormalizedAllocation[]
17029
+ * declare const confirmedBalanceAtomic: bigint
17030
+ *
17031
+ * const required = sumRequiredPerChain(estimatedIntents, allocations)
17032
+ * const overDrawn =
17033
+ * (required.get(Blockchain.Ethereum) ?? 0n) > confirmedBalanceAtomic
17034
+ * ```
17035
+ */ function sumRequiredPerChain(estimatedIntents, allocations) {
17036
+ const required = new Map();
17037
+ for (const intent of estimatedIntents){
17038
+ const chain = findBlockchainByDomain(intent.spec.sourceDomain, allocations);
17039
+ if (chain === undefined) continue;
17040
+ const amount = intentValue(intent) + intent.maxFee;
17041
+ required.set(chain, (required.get(chain) ?? 0n) + amount);
17042
+ }
17043
+ return required;
17044
+ }
16888
17045
 
16889
17046
  /**
16890
17047
  * Sign each adapter group: Solana one intent per signature, EVM batch per adapter.
@@ -17139,69 +17296,127 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17139
17296
  * non-forwarder transfer response is missing attestation or signature.
17140
17297
  * @throws KitError Propagated from adapter signing if the user rejects
17141
17298
  * or the signer is unavailable.
17142
- */ async function resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder) {
17143
- if (params.amountIn) {
17144
- const rawSources = Array.isArray(params.from) ? params.from : [
17145
- params.from
17146
- ];
17147
- const sourcesArray = rawSources.filter((s)=>s != null);
17148
- const networkType = destChain.isTestnet ? 'testnet' : 'mainnet';
17149
- const balanceResults = await Promise.all(sourcesArray.map(async (source)=>{
17150
- // When sourceAccount is set (delegate flow), scope the balance
17151
- // query to the Gateway depositor not the signer. Using the
17152
- // address-only path bypasses adapter address resolution, which
17153
- // would otherwise return the signer's balance (developer-
17154
- // controlled) or reject an explicit address (user-controlled).
17155
- let querySource;
17156
- if (source.sourceAccount) {
17157
- querySource = {
17158
- address: source.sourceAccount
17159
- };
17160
- } else {
17161
- querySource = {
17162
- adapter: source.adapter
17163
- };
17164
- if ('address' in source && source.address) {
17165
- querySource['address'] = source.address;
17166
- }
17167
- }
17168
- return getBalances$1({
17169
- token: params.token,
17170
- sources: querySource,
17171
- networkType
17172
- });
17173
- }));
17174
- const chainBalances = [];
17175
- for(let i = 0; i < balanceResults.length; i++){
17176
- const breakdowns = balanceResults[i]?.breakdown[0]?.breakdown ?? [];
17177
- for (const b of breakdowns){
17178
- chainBalances.push({
17179
- chain: b.chain,
17180
- confirmedBalance: b.confirmedBalance,
17181
- sourceIndex: i
17182
- });
17299
+ */ /**
17300
+ * Fetch confirmed per-chain USDC balances for every auto-allocation source.
17301
+ *
17302
+ * Used only on the `amountIn` (auto-allocation) path. Returns one
17303
+ * {@link ChainBalance} per (source, chain) pair so the greedy allocator — and
17304
+ * the corrective re-allocation pass — can reason about draw limits without a
17305
+ * second balance round-trip.
17306
+ *
17307
+ * @param params - Spend parameters (source(s) and token).
17308
+ * @param destChain - Resolved destination chain (used for network type).
17309
+ * @returns Confirmed balances tagged with their originating source index.
17310
+ */ async function fetchChainBalances(params, destChain) {
17311
+ const rawSources = Array.isArray(params.from) ? params.from : [
17312
+ params.from
17313
+ ];
17314
+ const sourcesArray = rawSources.filter((s)=>s != null);
17315
+ const networkType = destChain.isTestnet ? 'testnet' : 'mainnet';
17316
+ const balanceResults = await Promise.all(sourcesArray.map(async (source)=>{
17317
+ // When sourceAccount is set (delegate flow), scope the balance
17318
+ // query to the Gateway depositor — not the signer. Using the
17319
+ // address-only path bypasses adapter address resolution, which
17320
+ // would otherwise return the signer's balance (developer-
17321
+ // controlled) or reject an explicit address (user-controlled).
17322
+ let querySource;
17323
+ if (source.sourceAccount) {
17324
+ querySource = {
17325
+ address: source.sourceAccount
17326
+ };
17327
+ } else {
17328
+ querySource = {
17329
+ adapter: source.adapter
17330
+ };
17331
+ if ('address' in source && source.address) {
17332
+ querySource['address'] = source.address;
17183
17333
  }
17184
17334
  }
17185
- const customFeeConfig = params.config?.customFee;
17186
- const autoAllocResult = computeAutoAllocation({
17187
- amountIn: params.amountIn,
17188
- destinationChain: destChain.chain,
17189
- chainBalances,
17190
- useForwarder,
17191
- ...customFeeConfig ? {
17192
- customFee: customFeeConfig
17193
- } : {}
17335
+ return getBalances$1({
17336
+ token: params.token,
17337
+ sources: querySource,
17338
+ networkType
17194
17339
  });
17195
- const normalizedAutoAllocations = await normalizeAutoAllocations(autoAllocResult, sourcesArray);
17196
- const intents = buildAutoAllocatedBurnIntents(normalizedAutoAllocations, destChain, recipientAddress, params.token, params.config?.customFee);
17197
- const allocations = [
17198
- ...normalizedAutoAllocations.user,
17199
- ...normalizedAutoAllocations.devFee,
17200
- ...normalizedAutoAllocations.circleFee
17201
- ];
17340
+ }));
17341
+ const chainBalances = [];
17342
+ for(let i = 0; i < balanceResults.length; i++){
17343
+ const breakdowns = balanceResults[i]?.breakdown[0]?.breakdown ?? [];
17344
+ for (const b of breakdowns){
17345
+ chainBalances.push({
17346
+ chain: b.chain,
17347
+ confirmedBalance: b.confirmedBalance,
17348
+ sourceIndex: i
17349
+ });
17350
+ }
17351
+ }
17352
+ return chainBalances;
17353
+ }
17354
+ /**
17355
+ * Build auto-allocated normalised allocations and burn intents from
17356
+ * pre-fetched balances.
17357
+ *
17358
+ * Performs no balance API call, so it can be re-invoked with
17359
+ * `gasFeeOverrides` (the real per-chain gas from a prior estimate) to correct
17360
+ * an over-draw without re-querying balances.
17361
+ *
17362
+ * @param params - Spend parameters (source(s), token, optional custom fee).
17363
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17364
+ * @param recipientAddress - Resolved recipient address on the destination chain.
17365
+ * @param useForwarder - Whether the Forwarding Service path is active.
17366
+ * @param amountIn - Human-readable USDC amount to allocate.
17367
+ * @param chainBalances - Confirmed balances from {@link fetchChainBalances}.
17368
+ * @param gasFeeOverrides - Optional real per-chain gas fees to reserve.
17369
+ * @returns Normalised allocations and burn intents for the estimate/transfer API.
17370
+ */ async function buildAutoAllocatedFromBalances(params, destChain, recipientAddress, useForwarder, amountIn, chainBalances, gasFeeOverrides) {
17371
+ const rawSources = Array.isArray(params.from) ? params.from : [
17372
+ params.from
17373
+ ];
17374
+ const sourcesArray = rawSources.filter((s)=>s != null);
17375
+ const customFeeConfig = params.config?.customFee;
17376
+ const autoAllocResult = computeAutoAllocation({
17377
+ amountIn,
17378
+ destinationChain: destChain.chain,
17379
+ chainBalances,
17380
+ useForwarder,
17381
+ ...customFeeConfig ? {
17382
+ customFee: customFeeConfig
17383
+ } : {},
17384
+ ...gasFeeOverrides ? {
17385
+ gasFeeOverrides
17386
+ } : {}
17387
+ });
17388
+ const normalizedAutoAllocations = await normalizeAutoAllocations(autoAllocResult, sourcesArray);
17389
+ const intents = buildAutoAllocatedBurnIntents(normalizedAutoAllocations, destChain, recipientAddress, params.token, params.config?.customFee);
17390
+ const allocations = [
17391
+ ...normalizedAutoAllocations.user,
17392
+ ...normalizedAutoAllocations.devFee,
17393
+ ...normalizedAutoAllocations.circleFee
17394
+ ];
17395
+ return {
17396
+ allocations,
17397
+ intents
17398
+ };
17399
+ }
17400
+ /**
17401
+ * Resolve allocations and burn intents for the spend.
17402
+ *
17403
+ * Auto-allocation (`amountIn`) fetches balances once and returns them so the
17404
+ * caller can detect and correct over-draw without re-querying. Explicit
17405
+ * allocations return no balances (they are user-authoritative).
17406
+ *
17407
+ * @param params - Spend parameters.
17408
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17409
+ * @param recipientAddress - Resolved recipient address.
17410
+ * @param useForwarder - Whether the Forwarding Service path is active.
17411
+ * @returns Allocations, intents, and (auto-allocation only) confirmed balances.
17412
+ */ async function resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder) {
17413
+ if (params.amountIn) {
17414
+ const chainBalances = await fetchChainBalances(params, destChain);
17415
+ const { allocations, intents } = await buildAutoAllocatedFromBalances(params, destChain, recipientAddress, useForwarder, params.amountIn, chainBalances);
17202
17416
  return {
17203
17417
  allocations,
17204
- intents
17418
+ intents,
17419
+ chainBalances
17205
17420
  };
17206
17421
  }
17207
17422
  const allocations = await normalizeAllocations(params);
@@ -17243,6 +17458,148 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17243
17458
  forwardingFee: undefined
17244
17459
  };
17245
17460
  }
17461
+ /** Sum confirmed balances (atomic USDC) per source chain. */ function computeAvailablePerChain(chainBalances) {
17462
+ const available = new Map();
17463
+ for (const b of chainBalances){
17464
+ const atomic = parseUnits(b.confirmedBalance, USDC_DECIMALS$1);
17465
+ available.set(b.chain, (available.get(b.chain) ?? 0n) + atomic);
17466
+ }
17467
+ return available;
17468
+ }
17469
+ /**
17470
+ * Detect source chains whose required draw (value + maxFee across their
17471
+ * intents) exceeds the confirmed balance — the condition the Gateway API
17472
+ * rejects with `BALANCE_INSUFFICIENT_TOKEN` at `/v1/transfer`.
17473
+ *
17474
+ * Both sides are summed per chain (see {@link sumRequiredPerChain} and
17475
+ * {@link computeAvailablePerChain}), so detection is exact for the common
17476
+ * single-depositor-per-chain wallet. When multiple depositors hold USDC on the
17477
+ * same chain, a chain-level surplus can mask a per-depositor shortfall; the
17478
+ * API's own per-depositor `9001` remains the backstop in that case.
17479
+ */ function findOverdrawnChains(estimatedIntents, allocations, chainBalances) {
17480
+ const required = sumRequiredPerChain(estimatedIntents, allocations);
17481
+ const available = computeAvailablePerChain(chainBalances);
17482
+ const overdrawn = [];
17483
+ for (const [chain, req] of required){
17484
+ const avail = available.get(chain) ?? 0n;
17485
+ if (req > avail) {
17486
+ overdrawn.push({
17487
+ chain,
17488
+ required: req,
17489
+ available: avail
17490
+ });
17491
+ }
17492
+ }
17493
+ return overdrawn;
17494
+ }
17495
+ /**
17496
+ * Build a descriptive KitError for an auto-allocation gas shortfall that
17497
+ * survives the corrective re-allocation, naming the per-chain gap so the
17498
+ * caller sees the real cause instead of the opaque API 9001 rejection.
17499
+ */ function createAutoAllocationGasError(overdrawn, cause) {
17500
+ 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('; ');
17501
+ return new KitError({
17502
+ ...BalanceError.INSUFFICIENT_GAS,
17503
+ recoverability: 'FATAL',
17504
+ message: `Insufficient USDC to cover the transfer amount plus Gateway gas fees: ${detail}. ` + `Reduce the amount or add USDC on the affected chain(s).`,
17505
+ ...cause === undefined ? {} : {
17506
+ cause: {
17507
+ trace: {
17508
+ cause
17509
+ }
17510
+ }
17511
+ }
17512
+ });
17513
+ }
17514
+ /**
17515
+ * Validate allocations against the network/forwarder rules, call the estimate
17516
+ * API, and return the estimated intents with any forwarding fee.
17517
+ *
17518
+ * @param allocations - Normalised allocations for the estimate.
17519
+ * @param intents - Burn intents to estimate.
17520
+ * @param destChain - Resolved destination chain with Gateway v1 config.
17521
+ * @param useForwarder - Whether the Forwarding Service path is active.
17522
+ * @returns Estimated intents (with real maxFee) and optional forwarding fee.
17523
+ */ async function validateAndEstimate(allocations, intents, destChain, useForwarder) {
17524
+ assertNetworkCompatibility(allocations, destChain);
17525
+ if (useForwarder) {
17526
+ assertForwarderRouteSupport(destChain, allocations);
17527
+ }
17528
+ const apiBaseUrl = getGatewayApiBaseUrl(destChain.isTestnet);
17529
+ const estimateBody = buildEstimateRequestBody(intents);
17530
+ const { entries, forwardingFee } = await fetchEstimate(apiBaseUrl, estimateBody, useForwarder, allocations);
17531
+ const estimatedIntents = parseEstimateResponse(entries, intents);
17532
+ return {
17533
+ estimatedIntents,
17534
+ forwardingFee
17535
+ };
17536
+ }
17537
+ /**
17538
+ * Fold newly-observed per-chain gas into the accumulated overrides, keeping the
17539
+ * higher fee per chain so a chain a later pass reveals is never under-reserved.
17540
+ */ function mergeGasFeeOverrides(base, next) {
17541
+ const merged = new Map(base);
17542
+ for (const [chain, fee] of next){
17543
+ const prev = merged.get(chain);
17544
+ if (prev === undefined || fee > prev) {
17545
+ merged.set(chain, fee);
17546
+ }
17547
+ }
17548
+ return merged;
17549
+ }
17550
+ /**
17551
+ * Maximum corrective re-allocation passes before failing fast. One pass fixes
17552
+ * the common case; a second/third covers a chain that a spill only introduces
17553
+ * after gas is reserved. Bounds the worst case at this many extra estimate
17554
+ * round-trips (only ever reached when the balance genuinely falls short).
17555
+ */ const MAX_CORRECTION_PASSES = 3;
17556
+ /**
17557
+ * Correct an auto-allocation over-draw: reserve the estimate's real per-chain
17558
+ * gas, re-allocate from the same balances, and re-estimate — repeating up to
17559
+ * {@link MAX_CORRECTION_PASSES} times, accumulating the real gas each pass
17560
+ * reveals.
17561
+ *
17562
+ * One pass fixes the common case, where the over-drawn chain was already in the
17563
+ * first estimate. A further pass covers a chain that a spill only introduces
17564
+ * once gas is reserved on the destination: that chain isn't in the first
17565
+ * estimate, so its real gas is unknown until it appears, and its first
17566
+ * re-allocation falls back to the static reserve. Each pass folds the newly
17567
+ * revealed gas into the overrides (see {@link mergeGasFeeOverrides}) so the
17568
+ * next pass reserves it too. Per-chain gas is ~amount-independent, so once
17569
+ * every drawn chain's real gas is known the allocation converges.
17570
+ *
17571
+ * When the shortfall is genuine — the re-allocation can't cover the amount, or
17572
+ * the passes are exhausted while still over-drawn — throws a gas-specific
17573
+ * {@link KitError} instead of submitting a doomed transfer.
17574
+ */ async function correctOverdraw(opts) {
17575
+ let overrides = deriveGasFeeOverrides(opts.estimatedIntents, opts.allocations);
17576
+ let overdrawn = opts.overdrawn;
17577
+ for(let pass = 0; pass < MAX_CORRECTION_PASSES; pass++){
17578
+ let corrected;
17579
+ try {
17580
+ corrected = await buildAutoAllocatedFromBalances(opts.params, opts.destChain, opts.recipientAddress, opts.useForwarder, opts.amountIn, opts.chainBalances, overrides);
17581
+ } catch (err) {
17582
+ // Re-allocating with the real gas reserved can't cover the amount →
17583
+ // surface a gas-specific error instead of the opaque API rejection.
17584
+ if (err instanceof KitError && err.code === BalanceError.INSUFFICIENT_TOKEN.code) {
17585
+ throw createAutoAllocationGasError(overdrawn, err);
17586
+ }
17587
+ throw err;
17588
+ }
17589
+ const { estimatedIntents, forwardingFee } = await validateAndEstimate(corrected.allocations, corrected.intents, opts.destChain, opts.useForwarder);
17590
+ const stillOverdrawn = findOverdrawnChains(estimatedIntents, corrected.allocations, opts.chainBalances);
17591
+ if (stillOverdrawn.length === 0) {
17592
+ return {
17593
+ allocations: corrected.allocations,
17594
+ estimatedIntents,
17595
+ forwardingFee
17596
+ };
17597
+ }
17598
+ overdrawn = stillOverdrawn;
17599
+ overrides = mergeGasFeeOverrides(overrides, deriveGasFeeOverrides(estimatedIntents, corrected.allocations));
17600
+ }
17601
+ throw createAutoAllocationGasError(overdrawn);
17602
+ }
17246
17603
  /**
17247
17604
  * Validate allocations, call the estimate API, and return estimated intents.
17248
17605
  *
@@ -17250,6 +17607,14 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17250
17607
  * path. Handles forwarder route validation, network compatibility, and the
17251
17608
  * estimate API call.
17252
17609
  *
17610
+ * For auto-allocation (`amountIn`), the greedy allocator reserves a static
17611
+ * per-chain gas fee that can undershoot the API's real fee, draining a source
17612
+ * (typically the destination chain) below `value + maxFee` and triggering a
17613
+ * `BALANCE_INSUFFICIENT_TOKEN` rejection. When the first estimate reveals such
17614
+ * an over-draw, a bounded corrective re-allocation reserves the real gas and
17615
+ * re-estimates until it converges or fails fast (see {@link correctOverdraw}).
17616
+ * Explicit allocations are user-authoritative and never re-allocated.
17617
+ *
17253
17618
  * @param params - Spend parameters.
17254
17619
  * @param destChain - Resolved destination chain with Gateway v1 config.
17255
17620
  * @param recipientAddress - Resolved recipient address.
@@ -17259,15 +17624,24 @@ const FORWARDER_POLL_TIMEOUT_MS = 300_000;
17259
17624
  if (useForwarder) {
17260
17625
  assertForwarderRouteSupport(destChain);
17261
17626
  }
17262
- const { allocations, intents } = await resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder);
17263
- assertNetworkCompatibility(allocations, destChain);
17264
- if (useForwarder) {
17265
- assertForwarderRouteSupport(destChain, allocations);
17627
+ const { allocations, intents, chainBalances } = await resolveAllocationsAndIntents(params, destChain, recipientAddress, useForwarder);
17628
+ const { estimatedIntents, forwardingFee } = await validateAndEstimate(allocations, intents, destChain, useForwarder);
17629
+ if (params.amountIn && chainBalances) {
17630
+ const overdrawn = findOverdrawnChains(estimatedIntents, allocations, chainBalances);
17631
+ if (overdrawn.length > 0) {
17632
+ return correctOverdraw({
17633
+ params,
17634
+ destChain,
17635
+ recipientAddress,
17636
+ useForwarder,
17637
+ amountIn: params.amountIn,
17638
+ chainBalances,
17639
+ estimatedIntents,
17640
+ allocations,
17641
+ overdrawn
17642
+ });
17643
+ }
17266
17644
  }
17267
- const apiBaseUrl = getGatewayApiBaseUrl(destChain.isTestnet);
17268
- const estimateBody = buildEstimateRequestBody(intents);
17269
- const { entries, forwardingFee } = await fetchEstimate(apiBaseUrl, estimateBody, useForwarder, allocations);
17270
- const estimatedIntents = parseEstimateResponse(entries, intents);
17271
17645
  return {
17272
17646
  allocations,
17273
17647
  estimatedIntents,
@@ -18175,8 +18549,10 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18175
18549
  *
18176
18550
  * - `computeFee` — required function that receives resolved spend params
18177
18551
  * and returns a fee as a string (or `Promise<string>`).
18178
- * - `resolveFeeRecipientAddress` — required function that returns a
18179
- * recipient address as a string (or `Promise<string>`).
18552
+ * - `resolveFeeRecipientAddress` — optional function that returns a
18553
+ * recipient address as a string (or `Promise<string>`). Omit it when
18554
+ * using `setFeeRecipients()`'s declarative map instead — a policy
18555
+ * with neither throws at spend time.
18180
18556
  *
18181
18557
  * @example
18182
18558
  * ```ts
@@ -18189,7 +18565,7 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18189
18565
  * ```
18190
18566
  */ const customFeePolicySchema = z.object({
18191
18567
  computeFee: z.function().returns(z.string().or(z.promise(z.string()))),
18192
- resolveFeeRecipientAddress: z.function().returns(z.string().or(z.promise(z.string())))
18568
+ resolveFeeRecipientAddress: z.function().returns(z.string().or(z.promise(z.string()))).optional()
18193
18569
  }).strict();
18194
18570
  /**
18195
18571
  * Assert that the provided value conforms to {@link CustomFeePolicy}.
@@ -18211,6 +18587,71 @@ const assertCustomFeePolicySymbol = Symbol('assertCustomFeePolicy');
18211
18587
  validateWithStateTracking(config, customFeePolicySchema, 'UnifiedBalanceKit custom fee policy', assertCustomFeePolicySymbol);
18212
18588
  }
18213
18589
 
18590
+ const assertFeeRecipientsConfigSymbol = Symbol('assertFeeRecipientsConfig');
18591
+ /**
18592
+ * Schema for validating {@link FeeRecipientsConfig}.
18593
+ *
18594
+ * Requires at least one of `evm`/`solana`, non-empty string values for
18595
+ * whichever keys are present, and — mirroring the `depositAccount`
18596
+ * validation in `deposit/validate/assertions` — an address format that
18597
+ * matches the given chain type (EVM hex vs Solana base58).
18598
+ *
18599
+ * @example
18600
+ * ```ts
18601
+ * const config = {
18602
+ * evm: '0x1234567890123456789012345678901234567890',
18603
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
18604
+ * }
18605
+ * const result = feeRecipientsConfigSchema.safeParse(config)
18606
+ * // result.success === true
18607
+ * ```
18608
+ */ const feeRecipientsConfigSchema = z.object({
18609
+ evm: z.string().min(1, 'Fee recipient address is required.').optional(),
18610
+ solana: z.string().min(1, 'Fee recipient address is required.').optional()
18611
+ }).strict().refine((config)=>Object.keys(config).length > 0, {
18612
+ message: 'At least one fee recipient (evm or solana) is required.'
18613
+ }).superRefine((config, ctx)=>{
18614
+ for (const type of Object.keys(config)){
18615
+ const address = config[type];
18616
+ if (address == null) continue;
18617
+ // `{ name: type, type }` is a placeholder chain identifier — only
18618
+ // `.type` is checked by these two helpers today, `.name` is unused.
18619
+ // No real ChainDefinition exists here, since validation runs before
18620
+ // a destination chain is resolved.
18621
+ if (!isValidAddressForChain(address, {
18622
+ name: type,
18623
+ type
18624
+ })) {
18625
+ const { expectedAddressFormat } = extractChainInfo({
18626
+ name: type,
18627
+ type
18628
+ });
18629
+ ctx.addIssue({
18630
+ code: z.ZodIssueCode.custom,
18631
+ path: [
18632
+ type
18633
+ ],
18634
+ message: `Invalid ${type} address "${address}". Expected ${expectedAddressFormat}.`
18635
+ });
18636
+ }
18637
+ }
18638
+ });
18639
+ /**
18640
+ * Assert that the provided value conforms to {@link FeeRecipientsConfig}.
18641
+ *
18642
+ * Throws a validation error with annotated paths if the configuration is
18643
+ * malformed.
18644
+ *
18645
+ * @param config - The fee recipients map to validate.
18646
+ *
18647
+ * @example
18648
+ * ```ts
18649
+ * assertFeeRecipientsConfig({ evm: '0x1234567890123456789012345678901234567890' })
18650
+ * ```
18651
+ */ function assertFeeRecipientsConfig(config) {
18652
+ validateWithStateTracking(config, feeRecipientsConfigSchema, 'UnifiedBalanceKit fee recipients config', assertFeeRecipientsConfigSymbol);
18653
+ }
18654
+
18214
18655
  function sameChain(a, b) {
18215
18656
  return a.chain !== undefined && a.chain === b.chain;
18216
18657
  }
@@ -19191,6 +19632,105 @@ function assertSourceAccountAddresses(from) {
19191
19632
  config: params.config
19192
19633
  };
19193
19634
  }
19635
+ /**
19636
+ * Tracks, per {@link CustomFeePolicy} instance, which chain types have
19637
+ * already triggered the "falling back to resolveFeeRecipientAddress"
19638
+ * warning, so repeated `spend()`/`estimateSpend()` calls (e.g. live
19639
+ * quoting) warn once per (policy, chain type) pair rather than on every
19640
+ * call.
19641
+ */ const warnedFeeRecipientFallbacks = new WeakMap();
19642
+ /**
19643
+ * Invoke `resolveFeeRecipientAddress` and validate its return value has a
19644
+ * plausible address format for `destChain`, the same check
19645
+ * `setFeeRecipients()` already applies at config time. Unlike the map,
19646
+ * the callback's return value can't be validated ahead of time, so it's
19647
+ * checked here instead — a malformed value throws immediately rather
19648
+ * than silently becoming the fee recipient.
19649
+ *
19650
+ * @internal
19651
+ */ async function resolveFeeRecipientFromCallback(callback, destChain, params) {
19652
+ const address = await callback(destChain, params);
19653
+ if (!isValidAddressForChain(address, destChain)) {
19654
+ throw new KitError({
19655
+ ...InputError.VALIDATION_FAILED,
19656
+ recoverability: 'FATAL',
19657
+ message: `resolveFeeRecipientAddress returned an invalid address ` + `"${address}" for chain type "${destChain.type}" ` + `(resolved destination: ${destChain.name}).`
19658
+ });
19659
+ }
19660
+ return address;
19661
+ }
19662
+ /**
19663
+ * Resolve the single fee recipient address for a spend.
19664
+ *
19665
+ * Every fee burn intent in a spend mints to the same destination
19666
+ * chain regardless of which source chain(s) funded it, so exactly one
19667
+ * recipient address — valid on `destChain` — is ever needed.
19668
+ *
19669
+ * `feeRecipients` (set via `setFeeRecipients`) takes priority over the
19670
+ * policy's `resolveFeeRecipientAddress` callback for any chain type it
19671
+ * has an entry for, since it's a direct lookup and doesn't require
19672
+ * invoking developer code. For a chain type `feeRecipients` doesn't
19673
+ * cover, it falls back to `resolveFeeRecipientAddress` if one is
19674
+ * configured — a warning is logged once per (policy, chain type) pair
19675
+ * so the fallback isn't a silent surprise, without spamming repeated
19676
+ * `estimateSpend()` calls used for live quoting. Throws if neither
19677
+ * resolves `destChain`'s type, or if `resolveFeeRecipientAddress`
19678
+ * resolves it to a malformed address (see
19679
+ * {@link resolveFeeRecipientFromCallback}).
19680
+ *
19681
+ * @internal
19682
+ */ async function resolveFeeRecipient(destChain, policy, feeRecipients, params) {
19683
+ if (feeRecipients) {
19684
+ // `destChain.type` is `@core/chains`' broader `ChainType` union;
19685
+ // `FeeRecipientChainType` is the narrower subset this map supports
19686
+ // today. A type not present as a key simply has no configured
19687
+ // recipient, which is handled below.
19688
+ const type = destChain.type;
19689
+ const recipientAddress = feeRecipients[type];
19690
+ if (recipientAddress) {
19691
+ return recipientAddress;
19692
+ }
19693
+ if (policy.resolveFeeRecipientAddress) {
19694
+ const warnedTypes = warnedFeeRecipientFallbacks.get(policy);
19695
+ if (!warnedTypes?.has(type)) {
19696
+ warnedFeeRecipientFallbacks.set(policy, (warnedTypes ?? new Set()).add(type));
19697
+ 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.`);
19698
+ }
19699
+ return resolveFeeRecipientFromCallback(policy.resolveFeeRecipientAddress, destChain, params);
19700
+ }
19701
+ throw new KitError({
19702
+ ...InputError.VALIDATION_FAILED,
19703
+ recoverability: 'FATAL',
19704
+ 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.`
19705
+ });
19706
+ }
19707
+ if (!policy.resolveFeeRecipientAddress) {
19708
+ throw new KitError({
19709
+ ...InputError.VALIDATION_FAILED,
19710
+ recoverability: 'FATAL',
19711
+ message: 'No fee recipient configured — call setFeeRecipients() or provide ' + 'resolveFeeRecipientAddress on the custom fee policy.'
19712
+ });
19713
+ }
19714
+ return resolveFeeRecipientFromCallback(policy.resolveFeeRecipientAddress, destChain, params);
19715
+ }
19716
+ /**
19717
+ * Guard against a common misconfiguration: a developer sets the
19718
+ * declarative `feeRecipients` map expecting it alone to drive fee
19719
+ * collection, but no fee is ever charged without a `computeFee` from
19720
+ * `customFeePolicy` to determine the amount. Without this check that
19721
+ * misconfiguration fails silently — no fee is charged and no error is
19722
+ * raised.
19723
+ *
19724
+ * @internal
19725
+ */ function assertFeeRecipientsHasPolicy(feeRecipients) {
19726
+ if (feeRecipients) {
19727
+ throw new KitError({
19728
+ ...InputError.VALIDATION_FAILED,
19729
+ recoverability: 'FATAL',
19730
+ 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.'
19731
+ });
19732
+ }
19733
+ }
19194
19734
  /**
19195
19735
  * Apply a {@link CustomFeePolicy} to an adapter-only spend.
19196
19736
  *
@@ -19199,15 +19739,20 @@ function assertSourceAccountAddresses(from) {
19199
19739
  * `config.customFee` so the provider sees it.
19200
19740
  *
19201
19741
  * @internal
19202
- */ async function mergeCustomFeePolicyForAdapterOnly(params, policy) {
19203
- if (params.config?.customFee || !policy) {
19742
+ */ async function mergeCustomFeePolicyForAdapterOnly(params, policy, feeRecipients) {
19743
+ if (params.config?.customFee) {
19744
+ return params;
19745
+ }
19746
+ if (!policy) {
19747
+ assertFeeRecipientsHasPolicy(feeRecipients);
19204
19748
  return params;
19205
19749
  }
19206
19750
  const destChain = resolveChainIdentifier(params.to.chain);
19207
- const [feeValue, recipientAddress] = await Promise.all([
19208
- policy.computeFee(params),
19209
- policy.resolveFeeRecipientAddress(destChain, params)
19210
- ]);
19751
+ // Resolve the recipient before computing the fee: a KitError here
19752
+ // (missing/unresolvable recipient) shouldn't be preceded by an
19753
+ // otherwise-wasted computeFee call, which may be a network request.
19754
+ const recipientAddress = await resolveFeeRecipient(destChain, policy, feeRecipients, params);
19755
+ const feeValue = await policy.computeFee(params);
19211
19756
  return {
19212
19757
  ...params,
19213
19758
  config: {
@@ -19237,18 +19782,35 @@ function assertSourceAccountAddresses(from) {
19237
19782
  });
19238
19783
  }
19239
19784
  }
19240
- async function mergeCustomFeeConfig(resolved, policy) {
19241
- if (resolved.config?.customFee || !policy) {
19785
+ async function mergeCustomFeeConfig(resolved, policy, feeRecipients) {
19786
+ if (resolved.config?.customFee) {
19242
19787
  return resolved;
19243
19788
  }
19244
- const firstSourceChain = resolved.from[0]?.allocations[0]?.chain;
19245
- if (!firstSourceChain) {
19789
+ if (!policy) {
19790
+ assertFeeRecipientsHasPolicy(feeRecipients);
19246
19791
  return resolved;
19247
19792
  }
19248
- const [feeValue, recipientAddress] = await Promise.all([
19249
- policy.computeFee(resolved),
19250
- policy.resolveFeeRecipientAddress(firstSourceChain, resolved)
19251
- ]);
19793
+ // Skip fee resolution when there's no source chain to spend from at
19794
+ // all. This state can't arise from validated input today — the
19795
+ // caller re-checks and throws "No source chain found" right after
19796
+ // this returns — but skipping here isn't dead code: verified that
19797
+ // removing it lets a degenerate zero-allocation resolved value reach
19798
+ // computeFee/assertDeveloperFeeWithinBounds first, which throws a
19799
+ // misleading "Developer fee must be less than the total spend
19800
+ // amount" (0 >= 0 total allocation) instead of the correct "No
19801
+ // source chain found" error — or, for a real developer computeFee
19802
+ // that assumes a non-empty allocation, an uncaught raw exception
19803
+ // instead of any KitError at all. This guard exists to guarantee the
19804
+ // caller's clear error is what actually surfaces, not for
19805
+ // correctness.
19806
+ if (collectSourceChains(resolved).length === 0) {
19807
+ return resolved;
19808
+ }
19809
+ // Resolve the recipient before computing the fee: a KitError here
19810
+ // (missing/unresolvable recipient) shouldn't be preceded by an
19811
+ // otherwise-wasted computeFee call, which may be a network request.
19812
+ const recipientAddress = await resolveFeeRecipient(resolved.to.chain, policy, feeRecipients, resolved);
19813
+ const feeValue = await policy.computeFee(resolved);
19252
19814
  return {
19253
19815
  ...resolved,
19254
19816
  config: {
@@ -19304,14 +19866,14 @@ async function mergeCustomFeeConfig(resolved, policy) {
19304
19866
  }
19305
19867
  const destChain = resolveChainIdentifier(params.to.chain);
19306
19868
  if (!hasExplicitAllocations(params.from)) {
19307
- const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy);
19869
+ const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy, context.feeRecipients);
19308
19870
  assertDeveloperFeeWithinAmount(merged);
19309
19871
  const provider = findProviderForChain(context, normalizeToken(merged.token), destChain);
19310
19872
  return callSpend(provider, toProviderAdapterOnlyParams(merged));
19311
19873
  }
19312
19874
  const resolved = await resolveSpendParams(params);
19313
19875
  assertSpendNetworkCompatibility(resolved);
19314
- const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy);
19876
+ const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy, context.feeRecipients);
19315
19877
  assertDeveloperFeeWithinBounds(withFee);
19316
19878
  const sourceChains = collectSourceChains(withFee);
19317
19879
  if (sourceChains.length === 0) {
@@ -19351,14 +19913,14 @@ async function mergeCustomFeeConfig(resolved, policy) {
19351
19913
  assertSpendParams(params);
19352
19914
  const destChain = resolveChainIdentifier(params.to.chain);
19353
19915
  if (!hasExplicitAllocations(params.from)) {
19354
- const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy);
19916
+ const merged = await mergeCustomFeePolicyForAdapterOnly(params, context.customFeePolicy, context.feeRecipients);
19355
19917
  assertDeveloperFeeWithinAmount(merged);
19356
19918
  const provider = findProviderForChain(context, normalizeToken(merged.token), destChain);
19357
19919
  return provider.estimateSpend(toProviderAdapterOnlyParams(merged));
19358
19920
  }
19359
19921
  const resolved = await resolveSpendParams(params);
19360
19922
  assertSpendNetworkCompatibility(resolved);
19361
- const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy);
19923
+ const withFee = await mergeCustomFeeConfig(resolved, context.customFeePolicy, context.feeRecipients);
19362
19924
  assertDeveloperFeeWithinBounds(withFee);
19363
19925
  const sourceChains = collectSourceChains(withFee);
19364
19926
  if (sourceChains.length === 0) {
@@ -19895,7 +20457,11 @@ const removeFundParamsSchema = z.object({
19895
20457
  // Remove Fund Operations
19896
20458
  // ---------------------------------------------------------------------------
19897
20459
  /**
19898
- * Kick off a delayed fund removal from an account.
20460
+ * Kick off a delayed recovery fund removal from an account.
20461
+ *
20462
+ * Use `initiateRemoveFund` only as a trustless fallback when the normal spend
20463
+ * flow is unavailable. For day-to-day movement out of a Unified Balance, use
20464
+ * `spend`.
19899
20465
  *
19900
20466
  * Validates `from` and `amount`, resolves the chain and token via
19901
20467
  * {@link resolveRemoveFundParams}, selects the matching provider, then calls
@@ -19932,7 +20498,10 @@ const removeFundParamsSchema = z.object({
19932
20498
  return provider.initiateRemoveFund(resolved);
19933
20499
  }
19934
20500
  /**
19935
- * Complete a fund removal once the 7-day activation period has passed.
20501
+ * Complete a recovery fund removal once the 7-day withdrawal delay has passed.
20502
+ *
20503
+ * Use `removeFund` only as a trustless fallback when the normal spend flow is
20504
+ * unavailable. For day-to-day movement out of a Unified Balance, use `spend`.
19936
20505
  *
19937
20506
  * Validates `from`, resolves the chain and token via
19938
20507
  * {@link resolveRemoveFundParams}, selects the matching provider, then calls
@@ -20021,13 +20590,18 @@ const removeFundParamsSchema = z.object({
20021
20590
  /** SDK name used in telemetry payloads. */ const SDK_NAME = resolveKitSdkName(pkg.name);
20022
20591
  /**
20023
20592
  * A high-level class-based interface for cross-chain USDC deposits,
20024
- * spending, balance queries, delegation management, and withdrawals.
20593
+ * spending, balance queries, delegation management, and recovery fund removals.
20025
20594
  *
20026
20595
  * UnifiedBalanceKit provides a familiar class-based API for developers who
20027
20596
  * prefer traditional object-oriented patterns. The class maintains an
20028
20597
  * internal context and provides methods that delegate to the standalone
20029
20598
  * operation functions exported by this package.
20030
20599
  *
20600
+ * Use `spend` for normal movement out of a Unified Balance. `removeFund` is a
20601
+ * trustless recovery path for situations where the normal spend flow is
20602
+ * unavailable, and it requires a 7-day withdrawal delay before funds can be
20603
+ * removed.
20604
+ *
20031
20605
  * @remarks
20032
20606
  * For functional usage, import and use the operations directly:
20033
20607
  * ```typescript
@@ -20248,7 +20822,11 @@ const removeFundParamsSchema = z.object({
20248
20822
  });
20249
20823
  }
20250
20824
  /**
20251
- * Kick off a delayed fund removal from an account.
20825
+ * Kick off a delayed recovery fund removal from an account.
20826
+ *
20827
+ * Use this only as a trustless fallback when the normal spend flow is
20828
+ * unavailable. For day-to-day movement out of a Unified Balance, use
20829
+ * `spend`.
20252
20830
  *
20253
20831
  * @param params - The account owner's adapter context, amount, and
20254
20832
  * optional token type.
@@ -20262,7 +20840,12 @@ const removeFundParamsSchema = z.object({
20262
20840
  });
20263
20841
  }
20264
20842
  /**
20265
- * Complete a fund removal once the activation period has passed.
20843
+ * Complete a recovery fund removal once the 7-day withdrawal delay has
20844
+ * passed.
20845
+ *
20846
+ * Use this only as a trustless fallback when the normal spend flow is
20847
+ * unavailable. For day-to-day movement out of a Unified Balance, use
20848
+ * `spend`.
20266
20849
  *
20267
20850
  * @param params - The account owner context matching the original
20268
20851
  * fund removal initiation.
@@ -20333,6 +20916,46 @@ const removeFundParamsSchema = z.object({
20333
20916
  */ removeCustomFeePolicy() {
20334
20917
  delete this.context.customFeePolicy;
20335
20918
  }
20919
+ /**
20920
+ * Set a declarative fee recipient map, keyed by chain type. Once set,
20921
+ * `spend()`/`estimateSpend()` resolve the fee recipient by looking up
20922
+ * the spend's destination chain type in this map — taking priority
20923
+ * over `customFeePolicy`'s `resolveFeeRecipientAddress` callback.
20924
+ *
20925
+ * @remarks
20926
+ * This only controls which address a fee is sent to — it does not by
20927
+ * itself cause any fee to be charged. You still need
20928
+ * {@link UnifiedBalanceKit.setCustomFeePolicy}'s `computeFee` to
20929
+ * determine the fee amount; calling `setFeeRecipients` without ever
20930
+ * calling `setCustomFeePolicy` throws at spend time (there is no
20931
+ * `computeFee` to determine an amount).
20932
+ *
20933
+ * @param config - Fee recipient addresses keyed by chain type (e.g.
20934
+ * `{ evm: '0x...', solana: 'Sol...' }`). Provide entries for every
20935
+ * chain type you expect to spend to; spending to a chain type with
20936
+ * no matching entry throws before any fee collection is attempted.
20937
+ *
20938
+ * @example
20939
+ * ```typescript
20940
+ * kit.setFeeRecipients({
20941
+ * evm: '0x1234567890123456789012345678901234567890',
20942
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
20943
+ * })
20944
+ * ```
20945
+ */ setFeeRecipients(config) {
20946
+ assertFeeRecipientsConfig(config);
20947
+ this.context.feeRecipients = config;
20948
+ }
20949
+ /**
20950
+ * Remove the declarative fee recipient map for the kit.
20951
+ *
20952
+ * @example
20953
+ * ```typescript
20954
+ * kit.removeFeeRecipients()
20955
+ * ```
20956
+ */ removeFeeRecipients() {
20957
+ delete this.context.feeRecipients;
20958
+ }
20336
20959
  }
20337
20960
 
20338
20961
  // Auto-register this kit for user agent tracking
@@ -20349,6 +20972,11 @@ registerKit(`${pkg.name}/${pkg.version}`);
20349
20972
  * Internally holds a persistent {@link UnifiedBalanceKit} instance so that
20350
20973
  * event dispatchers and custom fee policies are preserved across calls.
20351
20974
  *
20975
+ * Use {@link AppKitUnifiedBalance.spend} for normal movement out of a Unified
20976
+ * Balance. {@link AppKitUnifiedBalance.removeFund} is a trustless recovery path
20977
+ * for situations where the normal spend flow is unavailable, and it requires a
20978
+ * 7-day withdrawal delay after {@link AppKitUnifiedBalance.initiateRemoveFund}.
20979
+ *
20352
20980
  * @example
20353
20981
  * ```typescript
20354
20982
  * import { AppKit } from '@circle-fin/app-kit'
@@ -20560,7 +21188,12 @@ registerKit(`${pkg.name}/${pkg.version}`);
20560
21188
  return this.kit.removeDelegate(params);
20561
21189
  }
20562
21190
  /**
20563
- * Kick off a delayed fund removal from an account.
21191
+ * Initiate a trustless recovery removal from an account.
21192
+ *
21193
+ * Use `spend` for normal movement out of a Unified Balance. `removeFund` is a
21194
+ * recovery path for situations where the normal spend flow is unavailable.
21195
+ * Calling this method starts the 7-day withdrawal delay before the removal can
21196
+ * be completed.
20564
21197
  *
20565
21198
  * @param params - The account owner's adapter context, amount, and token.
20566
21199
  * @returns Promise resolving to the initiation details.
@@ -20579,11 +21212,16 @@ registerKit(`${pkg.name}/${pkg.version}`);
20579
21212
  return this.kit.initiateRemoveFund(params);
20580
21213
  }
20581
21214
  /**
20582
- * Complete a fund removal once the activation period has passed.
21215
+ * Complete a trustless recovery removal after the withdrawal delay.
21216
+ *
21217
+ * Use `spend` for normal movement out of a Unified Balance. `removeFund` is a
21218
+ * recovery path for situations where the normal spend flow is unavailable.
21219
+ * Both EVM and Solana removals require a 7-day withdrawal delay after
21220
+ * `initiateRemoveFund` before funds can be removed.
20583
21221
  *
20584
21222
  * @param params - The account owner context matching the original initiation.
20585
21223
  * @returns Promise resolving to the fund removal details.
20586
- * @throws {KitError} If the activation period has not elapsed or the
21224
+ * @throws {KitError} If the withdrawal delay has not elapsed or the
20587
21225
  * on-chain transaction fails.
20588
21226
  *
20589
21227
  * @example
@@ -20659,6 +21297,45 @@ registerKit(`${pkg.name}/${pkg.version}`);
20659
21297
  */ removeCustomFeePolicy() {
20660
21298
  this.kit.removeCustomFeePolicy();
20661
21299
  }
21300
+ /**
21301
+ * Set a declarative fee recipient map, keyed by chain type.
21302
+ *
21303
+ * Once set, `spend()`/`estimateSpend()` resolve the fee recipient by
21304
+ * looking up the spend's destination chain type in this map — taking
21305
+ * priority over `customFeePolicy`'s `resolveFeeRecipientAddress`
21306
+ * callback.
21307
+ *
21308
+ * @remarks
21309
+ * This only controls which address a fee is sent to — it does not by
21310
+ * itself cause any fee to be charged. You still need
21311
+ * `setCustomFeePolicy`'s `computeFee` to determine the fee amount;
21312
+ * calling `setFeeRecipients` without ever calling `setCustomFeePolicy`
21313
+ * throws at spend time (there is no `computeFee` to determine an
21314
+ * amount).
21315
+ *
21316
+ * @param config - Fee recipient addresses keyed by chain type (e.g.
21317
+ * `{ evm: '0x...', solana: 'Sol...' }`).
21318
+ *
21319
+ * @example
21320
+ * ```typescript
21321
+ * kit.unifiedBalance.setFeeRecipients({
21322
+ * evm: '0x1234567890123456789012345678901234567890',
21323
+ * solana: '9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM',
21324
+ * })
21325
+ * ```
21326
+ */ setFeeRecipients(config) {
21327
+ this.kit.setFeeRecipients(config);
21328
+ }
21329
+ /**
21330
+ * Remove the declarative fee recipient map.
21331
+ *
21332
+ * @example
21333
+ * ```typescript
21334
+ * kit.unifiedBalance.removeFeeRecipients()
21335
+ * ```
21336
+ */ removeFeeRecipients() {
21337
+ this.kit.removeFeeRecipients();
21338
+ }
20662
21339
  }
20663
21340
 
20664
21341
  export { AppKitUnifiedBalance };