Developer insights

Unified Balance Kit: Production Safeguards and Recovery Patterns for spend( )

June 19, 2026
5
min read
June 19, 2026
5
min read

Summary

The final post in the Unified Balance Kit series covers the preflight checks and recovery paths apps need around spend(), especially when delegated flows or mint-side failures can affect execution.

By the time an app is ready to execute a spend from a unified USDC balance, it should already know more than “the user has enough USDC.” In Unified Balance Kit, that usually means being ready to call spend().

The app still needs to verify fees and delegate readiness before execution, track the flow while it is running, and handle mint-side failures differently from input or balance failures.

Before spend(): verify the route you actually support

The most common production mistake is to assume a route is good enough because it is technically possible. In practice, apps should verify the exact route they intend to support before execution.

That usually means inspecting fee composition, checking whether destination behavior changes the route, confirming that any required delegate relationship is actually ready, and verifying that the execution path still matches the product expectation. estimateSpend() is one of the main tools for that preflight work.

// Reuse the same params for estimateSpend() and spend() so the preflight matches the executed route.
const params = {
  amount: "1.00",
  token: "USDC",
  from: [{ adapter: evmAdapter }, { adapter: solanaAdapter }],
  to: {
    adapter: destinationAdapter,
    chain: "Arc_Testnet",
    recipientAddress,
  },
}

const estimate = await kit.unifiedBalance.estimateSpend(params)

const feeSummary = {
  providerFee: estimate.fees.find((fee) => fee.type === "provider")?.amount ?? "0",
  gasFee: estimate.fees.find((fee) => fee.type === "gasFee")?.amount ?? "0",
  forwarderFee: estimate.fees.find((fee) => fee.type === "forwarder")?.amount ?? "0",
}

console.log(feeSummary)
// ​​{
//   providerFee: "0",
//   gasFee: "0.0011",
//   forwarderFee: "0",
// }

Fees: treat them as route validation, not only display

Unified Balance Kit lets apps apply a custom fee during spend transactions, which can be useful for products that monetize payout or settlement flows. Custom fees apply on spends, not deposits.

There are two configuration patterns: set a default fee policy on the kit instance, or pass a fee inline on a specific spend() call, which overrides the active policy.

kit.unifiedBalance.setCustomFeePolicy({
  computeFee: (params) => {
    const total = Number(params.amount)
    return (total * 0.01).toFixed(6) // Example: 1% product fee
  },
  resolveFeeRecipientAddress: (feePayoutChain) => {
    return feePayoutChain.type === "solana"
      ? process.env.SOLANA_FEE_RECIPIENT!
      : process.env.EVM_FEE_RECIPIENT!
  },
})

const estimate = await kit.unifiedBalance.estimateSpend({
  amount: "100.00",
  token: "USDC",
  from: [{ adapter: evmAdapter }, { adapter: solanaAdapter }],
  to: {
    adapter: destinationAdapter,
    chain: "Arc_Testnet",
    recipientAddress,
  },
})


The response from estimateSpend() includes a fees field, which contains the fee breakdown for the proposed route.

[
  {
    type: "provider",
    token: "USDC",
    amount: "0.004287",
    allocations: [
      { chain: "Sei_Testnet", amount: "0.001294" },
      { chain: "Avalanche_Fuji", amount: "0.000971" },
      { chain: "Base_Sepolia", amount: "0.000895" },
    ],
  },
  {
    type: "gasFee",
    token: "USDC",
    amount: "0.205586",
    allocations: [
      { chain: "Arc_Testnet", amount: "0.0011" },
      { chain: "Sei_Testnet", amount: "0.001231" },
      { chain: "Avalanche_Fuji", amount: "0.022098" },
    ],
  },
  {
    type: "kit",
    token: "USDC",
    amount: "1.000000",
    allocations: [{ chain: "Arc_Testnet", amount: "1.000000" }],
    recipientAddress: "<EVM_FEE_RECIPIENT>",
  },
]

Three details matter operationally: 

  • custom fees are deducted from the requested spend amount rather than added on top of it
  • the custom fee must be strictly less than the spend amount or total explicit allocations if present
  • a spend with a custom fee is split across multiple burn intents.

This is why fees are not only a presentation concern. Before executing a spend, inspect estimateSpend() for the exact route you plan to ship and confirm the requested amount, the custom fee if any, and the final recipient-side amount after provider and forwarder fees where applicable.

If the integration applies custom fees across different destination types, verify the fee-recipient path on the exact routes you support before rollout. A fee model that looks correct in one route shape may behave differently in another.

During execution: treat delegation as runtime readiness

For Smart Contract Account (SCA) flows, delegation is not a one-time checkbox. It is part of the runtime lifecycle.

An SCA can deposit into Gateway, but it cannot sign the burn intent used to spend out. You need an Externally Owned Account (EOA) delegate on each chain where that SCA holds deposited funds.

const delegateConfig = {
  from: { adapter: ownerAdapter, chain: "Ethereum" },
  delegateAddress,
}

await kit.unifiedBalance.addDelegate(delegateConfig)

let status = await kit.unifiedBalance.getDelegateStatus(delegateConfig)

while (status === "pending") {
  await new Promise((resolve) => setTimeout(resolve, 5_000))
  status = await kit.unifiedBalance.getDelegateStatus(delegateConfig)
}

if (status !== "ready") {
  throw new Error("Delegate is not ready for spend()")
}


That has a direct implication for production apps: delegate state should be checked before every spend that depends on it. The relevant question is not “did we ever add a delegate?” but “is the delegate relationship ready for this spend right now?”

If the delegated path is not actually ready, the SDK returns a specific error that can be used for designing better user interfaces rather than a generic failure error, for example:

KitError:
{
  name: "BALANCE_INSUFFICIENT_GAS",
  code: 9002,
  type: "BALANCE",
  recoverability: "FATAL",
  message: "Insufficient ETH on Ethereum to cover gas fees",
  trace: {
    balance: "0",
    walletAddress: "<ownerAdapter wallet address>",
    chain: "Ethereum",
  },
}


getDelegateStatus()
returns none, pending, or ready. In practice, treat ready as the only spendable state for delegated flows.

Useful operating rules are to add the delegate before the first spend path that depends on it, treat pending as a real lifecycle state and poll until ready, revoke delegates during key rotation or access removal, and store which owner account, delegate, and chain were paired for each spendable balance source.

addDelegate() and removeDelegate() also return transaction metadata such as state, txHash, and explorerUrl, which is useful for audit logs, support tooling, and key-rotation workflows.

After failure: not all spend failures mean the same thing

Once the burn has been committed, a failed destination mint is a different class of problem from invalid input or insufficient balance. It should be handled as a mint-side recovery case, not as a signal to rerun the entire flow from the beginning.

If the app treats all failures as equivalent, it can end up retrying the wrong part of the flow or misreporting the state of the transfer to the user.

Unified Balance Kit exposes recoverability metadata through KitError.recoverability, and the SDK includes a retry path for certain mint-side failures. If your product depends on that behavior, validate the exact error shape and retry flow on the routes you support before treating it as part of your production runbook.

Recovery pattern: resume quickly and with the right state

If you implement a retry path, handle it quickly. Gateway attestations expire after 10 minutes if unused, and the spend result includes expirationBlock when that information is returned by Gateway.

import { AppKit, KitError } from "@circle-fin/app-kit"

const kit = new AppKit()

try {
  const result = await kit.unifiedBalance.spend(params)
  console.log("Success:", result.txHash)
} catch (error) {
  if (
    error instanceof KitError &&
    error.recoverability === "RESUMABLE" &&
    error.cause?.trace
  ) {
    const { attestation, signature } = error.cause.trace as {
      attestation: string
      signature: string
    }

    const retryResult = await kit.unifiedBalance.spend({
      ...params,
      config: {
        retry: { attestation, signature },
      },
    })

    console.log("Retry success:", retryResult.txHash)
  } else {
    throw error
  }
}


If the app plans to support mint-side recovery in production, persist enough state to resume safely:

  • requested destination chain and recipient
  • resolved allocation details
  • error recoverability
  • attestation and signature when present
  • expiration metadata when present
  • user-visible transfer status

The app needs enough context to distinguish a route that should be retried from one that should be resumed or surfaced to support or ops intervention.

The production question is not only “can spend() run?”

In development, it is easy to think of spend() as the moment that proves the integration works. In production, the more useful question is whether the app has enough context around spend() to behave predictably before, during, and after execution. Unified Balance Kit gives apps a higher-level execution surface, but production quality still depends on how deliberately those controls are designed around it.

If you’ve followed this series through, we hope the main takeaway is clear: Unified Balance Kit can simplify how value moves and give the application clearer balance, routing, and execution context.

Earlier in the series:

For the underlying Unified Balance and setup docs, see the Unified Balance developer docs and the fees concept page.

USDC is issued by regulated affiliates of Circle. See Circle’s list of regulatory authorizations.

Arc testnet is offered by Circle Technology Services, LLC ("CTS"). CTS is a software provider and does not provide regulated financial or advisory services. You are solely responsible for services you provide to users, including obtaining any necessary licenses or approvals and otherwise complying with applicable laws.

Arc has not been reviewed or approved by the New York State Department of Financial Services.

The product features described in these materials are for informational purposes only. All product features may be modified, delayed, or cancelled without prior notice, at any time and at the sole discretion of Circle Technology Services, LLC. Nothing herein constitutes a commitment, warranty, guarantee or investment advice.

Circle Technology Services, LLC (“CTS”) is a software provider and does not provide regulated financial or advisory services. You are solely responsible for services you provide to users, including obtaining any necessary licenses or approvals and otherwise complying with applicable laws. For additional details, please click here to see the Circle Developer terms of service.

Contents