@circle-fin/app-kit 1.6.0 → 1.7.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.
package/earn.mjs CHANGED
@@ -988,6 +988,151 @@ function createTransactionRevertedError(chain, reason, trace, txHash, explorerUr
988
988
  });
989
989
  }
990
990
 
991
+ /**
992
+ * Type guard to check if an error is a KitError instance.
993
+ *
994
+ * This guard enables TypeScript to narrow the type from `unknown` to
995
+ * `KitError`, providing access to structured error properties like
996
+ * code, name, and recoverability.
997
+ *
998
+ * @param error - Unknown error to check
999
+ * @returns True if error is KitError with proper type narrowing
1000
+ *
1001
+ * @example
1002
+ * ```typescript
1003
+ * import { isKitError } from '@core/errors'
1004
+ *
1005
+ * try {
1006
+ * await kit.bridge(params)
1007
+ * } catch (error) {
1008
+ * if (isKitError(error)) {
1009
+ * // TypeScript knows this is KitError
1010
+ * console.log(`Structured error: ${error.name} (${error.code})`)
1011
+ * } else {
1012
+ * console.log('Regular error:', error)
1013
+ * }
1014
+ * }
1015
+ * ```
1016
+ */
1017
+ function isKitError(error) {
1018
+ return error instanceof KitError;
1019
+ }
1020
+ /**
1021
+ * Error codes that are considered retryable by default.
1022
+ *
1023
+ * @remarks
1024
+ * These are typically transient errors that may succeed on retry:
1025
+ * - Network connectivity issues (3001, 3002)
1026
+ * - Provider unavailability (4001, 4002)
1027
+ * - RPC nonce errors (4003)
1028
+ */
1029
+ const DEFAULT_RETRYABLE_ERROR_CODES = [
1030
+ // Network errors
1031
+ 3001, // NETWORK_CONNECTION_FAILED
1032
+ 3002, // NETWORK_TIMEOUT
1033
+ // Provider errors
1034
+ 4001, // PROVIDER_UNAVAILABLE
1035
+ 4002, // PROVIDER_TIMEOUT
1036
+ 4003, // RPC_NONCE_ERROR
1037
+ ];
1038
+ /**
1039
+ * Checks if an error is retryable.
1040
+ *
1041
+ * @remarks
1042
+ * Check order for KitError instances:
1043
+ * 1. If `recoverability === 'RETRYABLE'` or `recoverability === 'RESUMABLE'`,
1044
+ * return `true` immediately (priority check).
1045
+ * 2. Otherwise, check if `error.code` is in `DEFAULT_RETRYABLE_ERROR_CODES` (fallback check).
1046
+ * 3. Non-KitError instances always return `false`.
1047
+ *
1048
+ * This two-tier approach allows both explicit recoverability control and
1049
+ * backward-compatible code-based retry logic.
1050
+ *
1051
+ * RETRYABLE errors indicate transient failures that may succeed on
1052
+ * subsequent attempts, such as network timeouts or temporary service
1053
+ * unavailability. These errors are safe to retry after a delay.
1054
+ *
1055
+ * RESUMABLE errors indicate a multi-phase operation that completed some phases
1056
+ * before failing (for example, a token approval landed but the execution
1057
+ * transaction failed). They are also retryable — re-running the operation is
1058
+ * safe — but callers that have a kit-level `retry()` should prefer it so that
1059
+ * already-completed phases are skipped.
1060
+ *
1061
+ * @param error - Unknown error to check
1062
+ * @returns True if error is retryable
1063
+ *
1064
+ * @example
1065
+ * ```typescript
1066
+ * import { isRetryableError } from '@core/errors'
1067
+ *
1068
+ * try {
1069
+ * await kit.bridge(params)
1070
+ * } catch (error) {
1071
+ * if (isRetryableError(error)) {
1072
+ * // Implement retry logic with exponential backoff
1073
+ * setTimeout(() => retryOperation(), 5000)
1074
+ * }
1075
+ * }
1076
+ * ```
1077
+ *
1078
+ * @example
1079
+ * ```typescript
1080
+ * import { isRetryableError, createNetworkConnectionError, KitError } from '@core/errors'
1081
+ *
1082
+ * // KitError with RETRYABLE recoverability (priority check)
1083
+ * const error1 = createNetworkConnectionError('Ethereum')
1084
+ * isRetryableError(error1) // true
1085
+ *
1086
+ * // KitError with default retryable code (fallback check)
1087
+ * const error2 = new KitError({
1088
+ * code: 3002, // NETWORK_TIMEOUT - in DEFAULT_RETRYABLE_ERROR_CODES
1089
+ * name: 'NETWORK_TIMEOUT',
1090
+ * type: 'NETWORK',
1091
+ * recoverability: 'FATAL', // Not RETRYABLE
1092
+ * message: 'Timeout',
1093
+ * })
1094
+ * isRetryableError(error2) // true (code 3002 is in default list)
1095
+ *
1096
+ * // KitError with non-retryable code and FATAL recoverability
1097
+ * const error3 = new KitError({
1098
+ * code: 1001,
1099
+ * name: 'INPUT_NETWORK_MISMATCH',
1100
+ * type: 'INPUT',
1101
+ * recoverability: 'FATAL',
1102
+ * message: 'Invalid input',
1103
+ * })
1104
+ * isRetryableError(error3) // false
1105
+ *
1106
+ * // KitError with RESUMABLE recoverability (partially-completed operation)
1107
+ * const error4 = new KitError({
1108
+ * code: 8101,
1109
+ * name: 'EARN_EXECUTION_FAILED',
1110
+ * type: 'SERVICE',
1111
+ * recoverability: 'RESUMABLE',
1112
+ * message: 'Execution failed after approval',
1113
+ * })
1114
+ * isRetryableError(error4) // true
1115
+ *
1116
+ * // Non-KitError
1117
+ * const error5 = new Error('Standard error')
1118
+ * isRetryableError(error5) // false
1119
+ * ```
1120
+ */
1121
+ function isRetryableError$1(error) {
1122
+ // Use proper type guard to check if it's a KitError
1123
+ if (isKitError(error)) {
1124
+ // Priority check: explicit recoverability. RESUMABLE errors are a subset of
1125
+ // retryable errors — re-running the operation is safe, but a kit-level
1126
+ // retry() can resume from the failed phase instead.
1127
+ if (error.recoverability === 'RETRYABLE' ||
1128
+ error.recoverability === 'RESUMABLE') {
1129
+ return true;
1130
+ }
1131
+ // Fallback check: error code against default retryable codes
1132
+ return DEFAULT_RETRYABLE_ERROR_CODES.includes(error.code);
1133
+ }
1134
+ return false;
1135
+ }
991
1136
  /**
992
1137
  * Safely extracts error message from any error type.
993
1138
  *
@@ -6510,6 +6655,120 @@ function validateOrThrow(value, schema, message) {
6510
6655
  }
6511
6656
  }
6512
6657
 
6658
+ /**
6659
+ * A type-safe event emitter for managing action-based event subscriptions.
6660
+ *
6661
+ * Actionable provides a strongly-typed publish/subscribe pattern for events,
6662
+ * where each event (action) has its own specific payload type. Handlers can
6663
+ * subscribe to specific events or use a wildcard to receive all events.
6664
+ *
6665
+ * @typeParam AllActions - A record mapping action names to their payload types.
6666
+ *
6667
+ * @example
6668
+ * ```typescript
6669
+ * import { Actionable } from '@circle-fin/bridge-kit/utils';
6670
+ *
6671
+ * // Define your action types
6672
+ * type TransferActions = {
6673
+ * started: { txHash: string; amount: string };
6674
+ * completed: { txHash: string; destinationTxHash: string };
6675
+ * failed: { error: Error };
6676
+ * };
6677
+ *
6678
+ * // Create an actionable instance
6679
+ * const transferEvents = new Actionable<TransferActions>();
6680
+ *
6681
+ * // Subscribe to a specific event
6682
+ * transferEvents.on('completed', (payload) => {
6683
+ * console.log(`Transfer completed with hash: ${payload.destinationTxHash}`);
6684
+ * });
6685
+ *
6686
+ * // Subscribe to all events
6687
+ * transferEvents.on('*', (payload) => {
6688
+ * console.log('Event received:', payload);
6689
+ * });
6690
+ *
6691
+ * // Dispatch an event
6692
+ * transferEvents.dispatch('completed', {
6693
+ * txHash: '0x123',
6694
+ * destinationTxHash: '0xabc'
6695
+ * });
6696
+ * ```
6697
+ */
6698
+ class Actionable {
6699
+ // Store event handlers by action key
6700
+ handlers = {};
6701
+ // Store wildcard handlers that receive all events
6702
+ wildcard = [];
6703
+ // Implementation that handles both overloads
6704
+ on(action, handler) {
6705
+ if (action === '*') {
6706
+ // Add to wildcard handlers array
6707
+ this.wildcard.push(handler);
6708
+ }
6709
+ else {
6710
+ // Initialize the action's handler array if it doesn't exist
6711
+ if (!this.handlers[action]) {
6712
+ this.handlers[action] = [];
6713
+ }
6714
+ // Add the handler to the specific action's array
6715
+ this.handlers[action].push(handler);
6716
+ }
6717
+ }
6718
+ // Implementation that handles both overloads
6719
+ off(action, handler) {
6720
+ if (action === '*') {
6721
+ // Find and remove the handler from wildcard array
6722
+ const index = this.wildcard.indexOf(handler);
6723
+ if (index !== -1) {
6724
+ this.wildcard.splice(index, 1);
6725
+ }
6726
+ }
6727
+ else if (this.handlers[action]) {
6728
+ // Check if there are handlers for this action
6729
+ // Find and remove the specific handler
6730
+ const index = this.handlers[action].indexOf(handler);
6731
+ if (index !== -1) {
6732
+ this.handlers[action].splice(index, 1);
6733
+ }
6734
+ }
6735
+ }
6736
+ /**
6737
+ * Dispatch an action with its payload to all registered handlers.
6738
+ *
6739
+ * This method notifies both:
6740
+ * - Handlers registered specifically for this action
6741
+ * - Wildcard handlers registered for all actions
6742
+ *
6743
+ * @param action - The action key identifying the event type.
6744
+ * @param payload - The data associated with the action.
6745
+ *
6746
+ * @example
6747
+ * ```typescript
6748
+ * type Actions = {
6749
+ * transferStarted: { amount: string; destination: string };
6750
+ * transferComplete: { txHash: string };
6751
+ * };
6752
+ *
6753
+ * const events = new Actionable<Actions>();
6754
+ *
6755
+ * // Dispatch an event
6756
+ * events.dispatch('transferStarted', {
6757
+ * amount: '100',
6758
+ * destination: '0xABC123'
6759
+ * });
6760
+ * ```
6761
+ */
6762
+ dispatch(action, payload) {
6763
+ // Execute all handlers registered for this specific action
6764
+ for (const h of this.handlers[action] ?? [])
6765
+ h(payload);
6766
+ // Execute all wildcard handlers
6767
+ for (const h of this.wildcard)
6768
+ h(payload);
6769
+ }
6770
+ }
6771
+
6513
6772
  /**
6514
6773
  * Convert a value from its smallest unit representation to a human-readable decimal string.
6515
6774
  *
@@ -7380,7 +7639,7 @@ function resolveKitSdkName(pkgName) {
7380
7639
  }
7381
7640
 
7382
7641
  var name$2 = "@circle-fin/bridge-kit";
7383
- var version$2 = "1.10.1";
7642
+ var version$2 = "1.10.2";
7384
7643
  var pkg$2 = {
7385
7644
  name: name$2,
7386
7645
  version: version$2};
@@ -8293,7 +8552,7 @@ resolveKitSdkName(pkg$2.name);
8293
8552
  registerKit(`${pkg$2.name}/${pkg$2.version}`);
8294
8553
 
8295
8554
  var name$1 = "@circle-fin/swap-kit";
8296
- var version$1 = "1.2.2";
8555
+ var version$1 = "1.2.3";
8297
8556
  var pkg$1 = {
8298
8557
  name: name$1,
8299
8558
  version: version$1};
@@ -11770,7 +12029,7 @@ resolveKitSdkName(pkg$1.name);
11770
12029
  registerKit(`${pkg$1.name}/${pkg$1.version}`);
11771
12030
 
11772
12031
  var name = "@circle-fin/earn-kit";
11773
- var version = "1.0.0";
12032
+ var version = "1.1.0";
11774
12033
  var pkg = {
11775
12034
  name: name,
11776
12035
  version: version};
@@ -12180,6 +12439,253 @@ function validateExecutionDeadline(executionParams) {
12180
12439
  }
12181
12440
  }
12182
12441
 
12442
+ /** Base discriminators shared by every earn action payload. */
12443
+ const EARN_ACTION_BASE = {
12444
+ protocol: 'earn',
12445
+ service: 'earn-service',
12446
+ };
12447
+ /**
12448
+ * Dispatch a step event for an earn operation through the kit's action
12449
+ * dispatcher.
12450
+ *
12451
+ * Builds the action payload for the given action/operation/step combination
12452
+ * and forwards it to any registered listeners (including wildcard `'*'`
12453
+ * listeners). The call is a no-op when no dispatcher has been registered, so
12454
+ * callers can invoke it unconditionally.
12455
+ *
12456
+ * The `approve` action only fires for `deposit` and `withdraw` operations and
12457
+ * only carries `approve` steps; the `deposit`, `withdraw`, and `claimRewards`
12458
+ * actions carry only `fetchParams` and `execute` steps. Mismatched
12459
+ * combinations are ignored defensively.
12460
+ *
12461
+ * @param dispatcher - The action dispatcher, or `undefined` if none registered.
12462
+ * @param action - The action name to dispatch under.
12463
+ * @param operation - The earn operation the step belongs to.
12464
+ * @param step - The step to deliver as the event payload.
12465
+ *
12466
+ * @example
12467
+ * ```typescript
12468
+ * dispatchEarnEvent(dispatcher, 'deposit', 'deposit', {
12469
+ * name: 'execute',
12470
+ * state: 'success',
12471
+ * txHash: '0xabc...',
12472
+ * })
12473
+ * ```
12474
+ */
12475
+ function dispatchEarnEvent(dispatcher, action, operation, step) {
12476
+ if (dispatcher === undefined) {
12477
+ return;
12478
+ }
12479
+ // Listener errors must never abort the financial operation flow. A throwing
12480
+ // listener on a 'success' event after a transaction has landed on-chain
12481
+ // would otherwise propagate to the caller as a RESUMABLE error and trigger
12482
+ // a retry that re-submits the same transaction (double-spend risk).
12483
+ try {
12484
+ if (action === 'approve') {
12485
+ // The approve action only applies to deposit/withdraw approval steps.
12486
+ if (step.name !== 'approve' || operation === 'claimRewards') {
12487
+ return;
12488
+ }
12489
+ dispatcher.dispatch('approve', {
12490
+ ...EARN_ACTION_BASE,
12491
+ operation,
12492
+ method: 'approve',
12493
+ values: step,
12494
+ });
12495
+ return;
12496
+ }
12497
+ // deposit / withdraw / claimRewards actions carry fetchParams + execute steps.
12498
+ if (step.name === 'approve') {
12499
+ return;
12500
+ }
12501
+ const payload = { ...EARN_ACTION_BASE, method: step.name, values: step };
12502
+ if (action === 'deposit') {
12503
+ dispatcher.dispatch('deposit', { ...payload, operation: 'deposit' });
12504
+ return;
12505
+ }
12506
+ if (action === 'withdraw') {
12507
+ dispatcher.dispatch('withdraw', { ...payload, operation: 'withdraw' });
12508
+ return;
12509
+ }
12510
+ if (action === 'claimRewards') {
12511
+ dispatcher.dispatch('claimRewards', {
12512
+ ...payload,
12513
+ operation: 'claimRewards',
12514
+ });
12515
+ return;
12516
+ }
12517
+ }
12518
+ catch {
12519
+ // Swallow listener errors. Observability of the failure is the listener's
12520
+ // responsibility; the operation flow continues unaffected.
12521
+ return;
12522
+ }
12523
+ // Reached only when `action` is not a known earn action. Thrown outside the
12524
+ // try/catch so a missing case surfaces as a developer error instead of being
12525
+ // silently swallowed alongside listener failures.
12526
+ const exhaustive = action;
12527
+ throw new Error(`Unhandled earn action: ${String(exhaustive)}`);
12528
+ }
12529
+
12530
+ const EARN_OPERATIONS = new Set([
12531
+ 'deposit',
12532
+ 'withdraw',
12533
+ 'claimRewards',
12534
+ ]);
12535
+ /**
12536
+ * Operations whose service params carry a top-level `amount` input field
12537
+ * (`deposit` and `withdraw`); `claimRewards` params have no `amount`. This
12538
+ * refers to the request input — distinct from the reward `amount` that appears
12539
+ * inside a claimRewards *result*.
12540
+ */
12541
+ const OPERATIONS_WITH_AMOUNT = new Set([
12542
+ 'deposit',
12543
+ 'withdraw',
12544
+ ]);
12545
+ /**
12546
+ * Validate that `params` carries the minimum shape `retry()` needs to resume
12547
+ * the given operation: an adapter context (`from`) and a `vaultAddress`, plus
12548
+ * an `amount` for `deposit`/`withdraw`. This is a structural sanity check, not
12549
+ * a full schema validation — operations re-validate their inputs when re-run —
12550
+ * but it lets a mismatched or forged trace fail the {@link isEarnErrorTrace}
12551
+ * guard cleanly instead of crashing deep inside the resume path.
12552
+ */
12553
+ function hasEarnServiceParamsShape(operation, params) {
12554
+ if (params['from'] === null || typeof params['from'] !== 'object') {
12555
+ return false;
12556
+ }
12557
+ if (typeof params['vaultAddress'] !== 'string') {
12558
+ return false;
12559
+ }
12560
+ if (OPERATIONS_WITH_AMOUNT.has(operation) &&
12561
+ typeof params['amount'] !== 'string') {
12562
+ return false;
12563
+ }
12564
+ return true;
12565
+ }
12566
+ /**
12567
+ * Wrap an error thrown during a multi-phase earn operation with step-tracking
12568
+ * and resume context.
12569
+ *
12570
+ * Produces a new {@link KitError} that preserves the original error's code,
12571
+ * name, type, and message, merges any existing `cause.trace`, and adds an
12572
+ * {@link EarnErrorTrace} (`provider`, `operation`, `steps`, `params`). When a
12573
+ * prior phase had already committed work (a successful step exists) and the
12574
+ * underlying error is retryable, the recoverability is upgraded to
12575
+ * `RESUMABLE` so callers know `retry()` can resume from the failed phase. A
12576
+ * fatal underlying error stays fatal — it cannot be resumed.
12577
+ *
12578
+ * Non-{@link KitError} inputs (which should not occur on provider code paths)
12579
+ * are normalized to a retryable {@link ServiceError.INTERNAL_ERROR}.
12580
+ *
12581
+ * @param error - The error caught from a phase of the operation.
12582
+ * @param context - The operation context used to build the trace.
12583
+ * @returns A new {@link KitError} carrying the {@link EarnErrorTrace}.
12584
+ *
12585
+ * @example
12586
+ * ```typescript
12587
+ * try {
12588
+ * await executeEarnAction(...)
12589
+ * } catch (error) {
12590
+ * throw augmentEarnError(error, {
12591
+ * providerName: 'EarnService',
12592
+ * operation: 'deposit',
12593
+ * steps,
12594
+ * params,
12595
+ * })
12596
+ * }
12597
+ * ```
12598
+ */
12599
+ function augmentEarnError(error, context) {
12600
+ const base = isKitError(error)
12601
+ ? error
12602
+ : new KitError({
12603
+ ...ServiceError.INTERNAL_ERROR,
12604
+ recoverability: 'RETRYABLE',
12605
+ message: getErrorMessage(error),
12606
+ });
12607
+ // Only upgrade to RESUMABLE when a prior *on-chain* phase succeeded. The
12608
+ // RESUMABLE label promises retry() can skip already-committed work — true
12609
+ // for a landed `approve` or `execute`, but a successful `fetchParams` saves
12610
+ // nothing (retry() re-fetches it anyway, since execution params/deadlines
12611
+ // expire), so it stays a plain RETRYABLE.
12612
+ const priorOnChainPhaseSucceeded = context.steps.some((step) => step.state === 'success' &&
12613
+ (step.name === 'approve' || step.name === 'execute'));
12614
+ const recoverability = priorOnChainPhaseSucceeded && base.recoverability === 'RETRYABLE'
12615
+ ? 'RESUMABLE'
12616
+ : base.recoverability;
12617
+ const existingTrace = base.cause?.trace !== undefined &&
12618
+ base.cause.trace !== null &&
12619
+ typeof base.cause.trace === 'object'
12620
+ ? base.cause.trace
12621
+ : {};
12622
+ // Snapshot the steps so the trace `retry()` reads can't be mutated by a
12623
+ // future code path that retains the live `EarnRunContext.steps` array.
12624
+ const trace = {
12625
+ ...existingTrace,
12626
+ provider: context.providerName,
12627
+ operation: context.operation,
12628
+ steps: [...context.steps],
12629
+ };
12630
+ // Store the original service params non-enumerably. `retry()` reads them by
12631
+ // direct property access (`trace.params`), but JSON.stringify, console.log,
12632
+ // and telemetry serializers skip non-enumerable properties — so a
12633
+ // permissioned call's `config.kitKey` (a `KIT_KEY:<id>:<secret>` value) and
12634
+ // the in-memory adapter never leak through a logged error. defineProperty
12635
+ // also redefines any enumerable `params` an upstream trace may have carried.
12636
+ Object.defineProperty(trace, 'params', {
12637
+ value: context.params,
12638
+ enumerable: false,
12639
+ writable: true,
12640
+ configurable: true,
12641
+ });
12642
+ return new KitError({
12643
+ code: base.code,
12644
+ name: base.name,
12645
+ type: base.type,
12646
+ recoverability,
12647
+ message: base.message,
12648
+ cause: { trace },
12649
+ });
12650
+ }
12651
+ /**
12652
+ * Runtime type guard for {@link EarnErrorTrace}.
12653
+ *
12654
+ * Verifies the value carries the fields `retry()` needs to resume an earn
12655
+ * operation: a provider name, a known operation, a steps array, and a `params`
12656
+ * object whose shape matches that operation (an adapter context and vault
12657
+ * address, plus an amount for `deposit`/`withdraw`). Used to validate
12658
+ * `KitError.cause.trace` before resuming so a mismatched or forged trace is
12659
+ * rejected cleanly rather than crashing inside the resume path.
12660
+ *
12661
+ * @param value - The candidate value (typically `error.cause?.trace`).
12662
+ * @returns `true` if `value` is a usable {@link EarnErrorTrace}.
12663
+ *
12664
+ * @example
12665
+ * ```typescript
12666
+ * const trace = error.cause?.trace
12667
+ * if (isEarnErrorTrace(trace)) {
12668
+ * console.log(trace.operation, trace.steps.length)
12669
+ * }
12670
+ * ```
12671
+ */
12672
+ function isEarnErrorTrace(value) {
12673
+ if (value === null || typeof value !== 'object') {
12674
+ return false;
12675
+ }
12676
+ const candidate = value;
12677
+ const operation = candidate['operation'];
12678
+ if (typeof candidate['provider'] !== 'string' ||
12679
+ typeof operation !== 'string' ||
12680
+ !EARN_OPERATIONS.has(operation) ||
12681
+ !Array.isArray(candidate['steps']) ||
12682
+ candidate['params'] === null ||
12683
+ typeof candidate['params'] !== 'object') {
12684
+ return false;
12685
+ }
12686
+ return hasEarnServiceParamsShape(operation, candidate['params']);
12687
+ }
12688
+
12183
12689
  // ---------------------------------------------------------------------------
12184
12690
  // Shared primitives
12185
12691
  // ---------------------------------------------------------------------------
@@ -13167,12 +13673,63 @@ async function fetchClaimRewardsQuote(params) {
13167
13673
  }
13168
13674
  }
13169
13675
 
13676
+ /**
13677
+ * Build the `pending` {@link EarnStep} for a phase about to start,
13678
+ * discriminating on `stepName` so the result is a typed union member rather
13679
+ * than an unsafe `as EarnStep` cast.
13680
+ *
13681
+ * @param stepName - The name of the phase about to start.
13682
+ * @returns The typed pending step for the phase.
13683
+ */
13684
+ function buildPendingStep(stepName) {
13685
+ if (stepName === 'fetchParams') {
13686
+ return { name: 'fetchParams', state: 'pending' };
13687
+ }
13688
+ return { name: stepName, state: 'pending' };
13689
+ }
13690
+ /**
13691
+ * Build the `success` {@link EarnStep} for a completed phase, discriminating
13692
+ * on `stepName` so the `fetchParams` variant (which has no `txHash` in its
13693
+ * type) cannot accidentally carry a transaction hash.
13694
+ *
13695
+ * @param stepName - The name of the completed phase.
13696
+ * @param txHash - The on-chain transaction hash, if any.
13697
+ * @returns The typed success step for the phase.
13698
+ */
13699
+ function buildSuccessStep(stepName, txHash) {
13700
+ if (stepName === 'fetchParams') {
13701
+ return { name: 'fetchParams', state: 'success' };
13702
+ }
13703
+ if (txHash === undefined) {
13704
+ return { name: stepName, state: 'success' };
13705
+ }
13706
+ return { name: stepName, state: 'success', txHash };
13707
+ }
13708
+ /**
13709
+ * Build the `error` {@link EarnStep} for a failed phase, discriminating on
13710
+ * `stepName` so the result is a typed union member rather than an unsafe
13711
+ * `as EarnStep` cast.
13712
+ *
13713
+ * @param stepName - The name of the failed phase.
13714
+ * @param errorMessage - The human-readable failure message.
13715
+ * @param error - The raw error that caused the phase to fail.
13716
+ * @returns The typed error step for the phase.
13717
+ */
13718
+ function buildErrorStep(stepName, errorMessage, error) {
13719
+ if (stepName === 'fetchParams') {
13720
+ return { name: 'fetchParams', state: 'error', errorMessage, error };
13721
+ }
13722
+ return { name: stepName, state: 'error', errorMessage, error };
13723
+ }
13170
13724
  /**
13171
13725
  * Earn Service provider for the Circle earn service API.
13172
13726
  *
13173
13727
  * Implement the {@link EarningProvider} interface for yield-bearing vault
13174
13728
  * operations including vault discovery, position queries, deposits,
13175
- * withdrawals, and reward claiming.
13729
+ * withdrawals, and reward claiming. Multi-phase operations (deposit,
13730
+ * withdraw, claimRewards) emit step-level events through the kit's action
13731
+ * dispatcher and attach step progress to thrown errors so callers can resume
13732
+ * via {@link EarnServiceProvider.retry}.
13176
13733
  *
13177
13734
  * @example
13178
13735
  * ```typescript
@@ -13203,6 +13760,8 @@ class EarnServiceProvider {
13203
13760
  name = 'EarnService';
13204
13761
  /** {@inheritdoc} */
13205
13762
  supportedChains = Object.keys(CHAIN_TO_API).map((chain) => resolveChainIdentifier(chain));
13763
+ /** {@inheritdoc} */
13764
+ actionDispatcher = undefined;
13206
13765
  defaultConfig;
13207
13766
  /**
13208
13767
  * Create a new EarnServiceProvider.
@@ -13213,12 +13772,49 @@ class EarnServiceProvider {
13213
13772
  constructor(config) {
13214
13773
  this.defaultConfig = config;
13215
13774
  }
13775
+ /** {@inheritdoc} */
13776
+ registerDispatcher(dispatcher) {
13777
+ this.actionDispatcher = dispatcher;
13778
+ }
13216
13779
  resolveConfig(config) {
13217
13780
  const resolvedConfig = this.defaultConfig === undefined
13218
13781
  ? config
13219
13782
  : { ...this.defaultConfig, ...config };
13220
13783
  return resolvedConfig;
13221
13784
  }
13785
+ /**
13786
+ * Run one phase of a multi-phase earn operation: dispatch a `pending` event,
13787
+ * execute it, then dispatch a `success` event (recording the resulting step)
13788
+ * or — on failure — dispatch an `error` event, record the failed step, and
13789
+ * re-throw the original error for the caller to wrap with full step context.
13790
+ *
13791
+ * @typeParam R - The phase's result type.
13792
+ * @param ctx - The operation context (dispatcher, operation, steps, params).
13793
+ * @param action - The action name to dispatch the events under.
13794
+ * @param stepName - The phase name.
13795
+ * @param run - The phase work to execute.
13796
+ * @param txHashOf - Extracts the on-chain transaction hash from the result, if any.
13797
+ * @returns The phase result.
13798
+ */
13799
+ async runPhase(ctx, action, stepName, run, txHashOf) {
13800
+ dispatchEarnEvent(ctx.dispatcher, action, ctx.operation, buildPendingStep(stepName));
13801
+ let result;
13802
+ try {
13803
+ result = await run();
13804
+ }
13805
+ catch (error) {
13806
+ const failedStep = buildErrorStep(stepName, getErrorMessage(error), error);
13807
+ ctx.steps.push(failedStep);
13808
+ dispatchEarnEvent(ctx.dispatcher, action, ctx.operation, failedStep);
13809
+ throw error;
13810
+ }
13811
+ // Discriminate on stepName so the `fetchParams` variant (which has no
13812
+ // `txHash` field in its type) cannot accidentally pick up a tx hash.
13813
+ const successStep = buildSuccessStep(stepName, txHashOf(result));
13814
+ ctx.steps.push(successStep);
13815
+ dispatchEarnEvent(ctx.dispatcher, action, ctx.operation, successStep);
13816
+ return result;
13817
+ }
13222
13818
  /** {@inheritdoc} */
13223
13819
  async getVaults(params) {
13224
13820
  const config = this.resolveConfig(params.config);
@@ -13240,146 +13836,246 @@ class EarnServiceProvider {
13240
13836
  }
13241
13837
  /** {@inheritdoc} */
13242
13838
  async deposit(params) {
13243
- const config = this.resolveConfig(params.config);
13244
- const { address, chain: apiChain, chainDefinition: chain, } = await resolveAdapterContext(params.from);
13245
- const adapterContractAddress = requireAdapterContract(chain);
13246
- const rawUsdcAddress = chain.usdcAddress;
13247
- if (rawUsdcAddress === null) {
13248
- throw createUnsupportedTokenError('USDC', chain.name);
13249
- }
13250
- const usdcAddress = assertHexAddress('chain.usdcAddress', rawUsdcAddress, `USDC address for chain ${chain.name} must be a 0x-prefixed 20-byte hex address.`);
13251
- const { executionParams, signature } = await fetchDeposit({
13252
- vaultAddress: params.vaultAddress,
13253
- amount: params.amount,
13254
- address,
13255
- chain: apiChain,
13256
- config,
13257
- });
13258
- validateExecutionDeadline(executionParams);
13259
- const { adapter } = params.from;
13260
- const tokenInputs = buildEarnTokenInputs(executionParams, usdcAddress);
13261
- const approvalToken = tokenInputs[0]?.token;
13262
- if (approvalToken !== undefined) {
13263
- await approveMaxIfNeeded({
13839
+ return this.runDepositFlow(params, { skipApprove: false });
13840
+ }
13841
+ async runDepositFlow(params, options) {
13842
+ const ctx = {
13843
+ dispatcher: this.actionDispatcher,
13844
+ operation: 'deposit',
13845
+ steps: [],
13846
+ params,
13847
+ };
13848
+ try {
13849
+ const config = this.resolveConfig(params.config);
13850
+ const { address, chain: apiChain, chainDefinition: chain, } = await resolveAdapterContext(params.from);
13851
+ const adapterContractAddress = requireAdapterContract(chain);
13852
+ const rawUsdcAddress = chain.usdcAddress;
13853
+ if (rawUsdcAddress === null) {
13854
+ throw createUnsupportedTokenError('USDC', chain.name);
13855
+ }
13856
+ const usdcAddress = assertHexAddress('chain.usdcAddress', rawUsdcAddress, `USDC address for chain ${chain.name} must be a 0x-prefixed 20-byte hex address.`);
13857
+ const { adapter } = params.from;
13858
+ const { executionParams, signature } = await this.runPhase(ctx, 'deposit', 'fetchParams', async () => fetchDeposit({
13859
+ vaultAddress: params.vaultAddress,
13860
+ amount: params.amount,
13861
+ address,
13862
+ chain: apiChain,
13863
+ config,
13864
+ }), () => undefined);
13865
+ validateExecutionDeadline(executionParams);
13866
+ const tokenInputs = buildEarnTokenInputs(executionParams, usdcAddress);
13867
+ const approvalToken = tokenInputs[0]?.token;
13868
+ if (!options.skipApprove && approvalToken !== undefined) {
13869
+ await this.runPhase(ctx, 'approve', 'approve', async () => approveMaxIfNeeded({
13870
+ adapter,
13871
+ chain,
13872
+ tokenAddress: approvalToken,
13873
+ delegate: adapterContractAddress,
13874
+ address,
13875
+ revertMessage: 'USDC approval reverted on-chain',
13876
+ }), (txHash) => txHash);
13877
+ }
13878
+ const { txHash, explorerUrl } = await this.runPhase(ctx, 'deposit', 'execute', async () => executeEarnAction({
13264
13879
  adapter,
13265
13880
  chain,
13266
- tokenAddress: approvalToken,
13267
- delegate: adapterContractAddress,
13268
13881
  address,
13269
- revertMessage: 'USDC approval reverted on-chain',
13882
+ actionKey: 'earn.deposit',
13883
+ actionParams: {
13884
+ executeParams: executionParams,
13885
+ tokenInputs,
13886
+ signature,
13887
+ },
13888
+ revertMessage: 'Earn deposit reverted on-chain',
13889
+ }), ({ txHash }) => txHash);
13890
+ return {
13891
+ txHash,
13892
+ explorerUrl,
13893
+ vaultAddress: params.vaultAddress,
13894
+ amount: params.amount,
13895
+ };
13896
+ }
13897
+ catch (error) {
13898
+ throw augmentEarnError(error, {
13899
+ providerName: this.name,
13900
+ operation: ctx.operation,
13901
+ steps: ctx.steps,
13902
+ params: ctx.params,
13270
13903
  });
13271
13904
  }
13272
- const { txHash, explorerUrl } = await executeEarnAction({
13273
- adapter,
13274
- chain,
13275
- address,
13276
- actionKey: 'earn.deposit',
13277
- actionParams: {
13278
- executeParams: executionParams,
13279
- tokenInputs,
13280
- signature,
13281
- },
13282
- revertMessage: 'Earn deposit reverted on-chain',
13283
- });
13284
- return {
13285
- txHash,
13286
- explorerUrl,
13287
- vaultAddress: params.vaultAddress,
13288
- amount: params.amount,
13289
- };
13290
13905
  }
13291
13906
  /** {@inheritdoc} */
13292
13907
  async withdraw(params) {
13293
- const config = this.resolveConfig(params.config);
13294
- const { address, chain: apiChain, chainDefinition: chain, } = await resolveAdapterContext(params.from);
13295
- const adapterContractAddress = requireAdapterContract(chain);
13296
- const vaultAddress = assertHexAddress('vaultAddress', params.vaultAddress, 'Vault address must be a 0x-prefixed 20-byte hex address.');
13297
- const { executionParams, signature } = await fetchWithdraw({
13298
- vaultAddress,
13299
- amount: params.amount,
13300
- address,
13301
- chain: apiChain,
13302
- config,
13303
- });
13304
- validateExecutionDeadline(executionParams);
13305
- const { adapter } = params.from;
13306
- const tokenInputs = buildEarnTokenInputs(executionParams, vaultAddress);
13307
- const approvalToken = tokenInputs[0]?.token;
13308
- if (approvalToken !== undefined) {
13309
- await approveMaxIfNeeded({
13908
+ return this.runWithdrawFlow(params, { skipApprove: false });
13909
+ }
13910
+ async runWithdrawFlow(params, options) {
13911
+ const ctx = {
13912
+ dispatcher: this.actionDispatcher,
13913
+ operation: 'withdraw',
13914
+ steps: [],
13915
+ params,
13916
+ };
13917
+ try {
13918
+ const config = this.resolveConfig(params.config);
13919
+ const { address, chain: apiChain, chainDefinition: chain, } = await resolveAdapterContext(params.from);
13920
+ const adapterContractAddress = requireAdapterContract(chain);
13921
+ const vaultAddress = assertHexAddress('vaultAddress', params.vaultAddress, 'Vault address must be a 0x-prefixed 20-byte hex address.');
13922
+ const { adapter } = params.from;
13923
+ const { executionParams, signature } = await this.runPhase(ctx, 'withdraw', 'fetchParams', async () => fetchWithdraw({
13924
+ vaultAddress,
13925
+ amount: params.amount,
13926
+ address,
13927
+ chain: apiChain,
13928
+ config,
13929
+ }), () => undefined);
13930
+ validateExecutionDeadline(executionParams);
13931
+ const tokenInputs = buildEarnTokenInputs(executionParams, vaultAddress);
13932
+ const approvalToken = tokenInputs[0]?.token;
13933
+ if (!options.skipApprove && approvalToken !== undefined) {
13934
+ await this.runPhase(ctx, 'approve', 'approve', async () => approveMaxIfNeeded({
13935
+ adapter,
13936
+ chain,
13937
+ tokenAddress: approvalToken,
13938
+ delegate: adapterContractAddress,
13939
+ address,
13940
+ revertMessage: 'Vault share token approval reverted on-chain',
13941
+ }), (txHash) => txHash);
13942
+ }
13943
+ const { txHash, explorerUrl } = await this.runPhase(ctx, 'withdraw', 'execute', async () => executeEarnAction({
13310
13944
  adapter,
13311
13945
  chain,
13312
- tokenAddress: approvalToken,
13313
- delegate: adapterContractAddress,
13314
13946
  address,
13315
- revertMessage: 'Vault share token approval reverted on-chain',
13947
+ actionKey: 'earn.withdraw',
13948
+ actionParams: {
13949
+ executeParams: executionParams,
13950
+ tokenInputs,
13951
+ signature,
13952
+ },
13953
+ revertMessage: 'Earn withdraw reverted on-chain',
13954
+ }), ({ txHash }) => txHash);
13955
+ return {
13956
+ txHash,
13957
+ explorerUrl,
13958
+ vaultAddress,
13959
+ amount: params.amount,
13960
+ };
13961
+ }
13962
+ catch (error) {
13963
+ throw augmentEarnError(error, {
13964
+ providerName: this.name,
13965
+ operation: ctx.operation,
13966
+ steps: ctx.steps,
13967
+ params: ctx.params,
13316
13968
  });
13317
13969
  }
13318
- const { txHash, explorerUrl } = await executeEarnAction({
13319
- adapter,
13320
- chain,
13321
- address,
13322
- actionKey: 'earn.withdraw',
13323
- actionParams: {
13324
- executeParams: executionParams,
13325
- tokenInputs,
13326
- signature,
13327
- },
13328
- revertMessage: 'Earn withdraw reverted on-chain',
13329
- });
13330
- return {
13331
- txHash,
13332
- explorerUrl,
13333
- vaultAddress,
13334
- amount: params.amount,
13335
- };
13336
13970
  }
13337
13971
  /** {@inheritdoc} */
13338
13972
  async claimRewards(params) {
13339
- const config = this.resolveConfig(params.config);
13340
- const { address, chain: apiChain, chainDefinition: chain, } = await resolveAdapterContext(params.from);
13341
- // Claim rewards has no approval step, but still requires adapter support.
13342
- requireAdapterContract(chain);
13343
- const { rewards, executionParams, signature } = await fetchClaimRewards({
13344
- address,
13345
- chain: apiChain,
13346
- vaultAddress: params.vaultAddress,
13347
- config,
13348
- });
13349
- const nothingToClaim = rewards.length === 0;
13350
- if (nothingToClaim) {
13351
- return { status: 'no_rewards', rewards: [] };
13352
- }
13353
- const missingExecutionParams = executionParams === undefined;
13354
- const missingSignature = signature === undefined;
13355
- if (missingExecutionParams || missingSignature) {
13356
- throw new KitError({
13357
- ...EarnError.INTERNAL_ERROR,
13358
- recoverability: 'RETRYABLE',
13359
- message: 'Claim rewards response must include executionParams and signature when rewards are claimable',
13360
- cause: {
13361
- trace: {
13362
- rewardsCount: rewards.length,
13363
- missingExecutionParams,
13364
- missingSignature,
13973
+ return this.runClaimRewardsFlow(params);
13974
+ }
13975
+ async runClaimRewardsFlow(params) {
13976
+ const ctx = {
13977
+ dispatcher: this.actionDispatcher,
13978
+ operation: 'claimRewards',
13979
+ steps: [],
13980
+ params,
13981
+ };
13982
+ try {
13983
+ const config = this.resolveConfig(params.config);
13984
+ const { address, chain: apiChain, chainDefinition: chain, } = await resolveAdapterContext(params.from);
13985
+ // Claim rewards has no approval step, but still requires adapter support.
13986
+ requireAdapterContract(chain);
13987
+ const { rewards, executionParams, signature } = await this.runPhase(ctx, 'claimRewards', 'fetchParams', async () => fetchClaimRewards({
13988
+ address,
13989
+ chain: apiChain,
13990
+ vaultAddress: params.vaultAddress,
13991
+ config,
13992
+ }), () => undefined);
13993
+ const nothingToClaim = rewards.length === 0;
13994
+ if (nothingToClaim) {
13995
+ return { status: 'no_rewards', rewards: [] };
13996
+ }
13997
+ const missingExecutionParams = executionParams === undefined;
13998
+ const missingSignature = signature === undefined;
13999
+ if (missingExecutionParams || missingSignature) {
14000
+ throw new KitError({
14001
+ ...EarnError.INTERNAL_ERROR,
14002
+ recoverability: 'RETRYABLE',
14003
+ message: 'Claim rewards response must include executionParams and signature when rewards are claimable',
14004
+ cause: {
14005
+ trace: {
14006
+ rewardsCount: rewards.length,
14007
+ missingExecutionParams,
14008
+ missingSignature,
14009
+ },
13365
14010
  },
14011
+ });
14012
+ }
14013
+ validateExecutionDeadline(executionParams);
14014
+ const { txHash, explorerUrl } = await this.runPhase(ctx, 'claimRewards', 'execute', async () => executeEarnAction({
14015
+ adapter: params.from.adapter,
14016
+ chain,
14017
+ address,
14018
+ actionKey: 'earn.claimRewards',
14019
+ actionParams: {
14020
+ executeParams: executionParams,
14021
+ tokenInputs: [],
14022
+ signature,
13366
14023
  },
14024
+ revertMessage: 'Earn claim rewards reverted on-chain',
14025
+ }), ({ txHash }) => txHash);
14026
+ return { status: 'claimed', rewards, txHash, explorerUrl };
14027
+ }
14028
+ catch (error) {
14029
+ throw augmentEarnError(error, {
14030
+ providerName: this.name,
14031
+ operation: ctx.operation,
14032
+ steps: ctx.steps,
14033
+ params: ctx.params,
13367
14034
  });
13368
14035
  }
13369
- validateExecutionDeadline(executionParams);
13370
- const { txHash, explorerUrl } = await executeEarnAction({
13371
- adapter: params.from.adapter,
13372
- chain,
13373
- address,
13374
- actionKey: 'earn.claimRewards',
13375
- actionParams: {
13376
- executeParams: executionParams,
13377
- tokenInputs: [],
13378
- signature,
13379
- },
13380
- revertMessage: 'Earn claim rewards reverted on-chain',
13381
- });
13382
- return { status: 'claimed', rewards, txHash, explorerUrl };
14036
+ }
14037
+ /** {@inheritdoc} */
14038
+ supportsRetry(error) {
14039
+ return (isKitError(error) &&
14040
+ isRetryableError$1(error) &&
14041
+ isEarnErrorTrace(error.cause?.trace) &&
14042
+ error.cause.trace.provider === this.name);
14043
+ }
14044
+ /** {@inheritdoc} */
14045
+ async retry(error) {
14046
+ // Validation order mirrors EarnKit.retry().
14047
+ if (!isKitError(error)) {
14048
+ throw createValidationFailedError('error', error, 'retry() requires a KitError thrown by a previous earn operation');
14049
+ }
14050
+ if (!isRetryableError$1(error)) {
14051
+ throw createValidationFailedError('error.recoverability', error.recoverability, 'retry() requires a retryable or resumable error — check isRetryableError(error) first');
14052
+ }
14053
+ const trace = error.cause?.trace;
14054
+ if (!isEarnErrorTrace(trace)) {
14055
+ throw createValidationFailedError('error.cause.trace', trace, 'retry() requires a KitError carrying earn retry context (operation, steps, provider, params)');
14056
+ }
14057
+ if (trace.provider !== this.name) {
14058
+ throw createValidationFailedError('error.cause.trace.provider', trace.provider, `Cannot retry: error was produced by provider "${trace.provider}", not "${this.name}"`);
14059
+ }
14060
+ const approveCompleted = trace.steps.some((step) => step.name === 'approve' && step.state === 'success');
14061
+ // `trace` is a discriminated union on `operation`, so each branch narrows
14062
+ // `trace.params` to the matching service-params type — no cast needed.
14063
+ switch (trace.operation) {
14064
+ case 'deposit':
14065
+ return this.runDepositFlow(trace.params, {
14066
+ skipApprove: approveCompleted,
14067
+ });
14068
+ case 'withdraw':
14069
+ return this.runWithdrawFlow(trace.params, {
14070
+ skipApprove: approveCompleted,
14071
+ });
14072
+ case 'claimRewards':
14073
+ return this.runClaimRewardsFlow(trace.params);
14074
+ default: {
14075
+ const exhaustive = trace;
14076
+ throw createValidationFailedError('error.cause.trace.operation', exhaustive, 'retry() does not support this earn operation');
14077
+ }
14078
+ }
13383
14079
  }
13384
14080
  /** {@inheritdoc} */
13385
14081
  async getDepositQuote(params) {
@@ -13463,54 +14159,6 @@ function createEarnKitContext(config = {}) {
13463
14159
  return context;
13464
14160
  }
13465
14161
 
13466
- /**
13467
- * Symbol used to track that assertEarnParams has validated an object.
13468
- * @internal
13469
- */
13470
- const ASSERT_EARN_PARAMS_SYMBOL = Symbol('assertEarnParams');
13471
- /**
13472
- * Assert that the provided value conforms to the given earn params schema.
13473
- *
13474
- * Validate earn parameters using the provided Zod schema and track
13475
- * validation state to avoid duplicate checks. Throw a structured
13476
- * error with detailed validation messages if any parameter is invalid.
13477
- *
13478
- * @typeParam T - The expected type after validation
13479
- * @param params - The earn parameters to validate
13480
- * @param schema - The Zod schema to validate against
13481
- * @throws {@link KitError} If the parameters fail validation
13482
- *
13483
- * @example
13484
- * ```typescript
13485
- * import { assertEarnParams, depositParamsSchema } from '@circle-fin/earn-kit'
13486
- *
13487
- * assertEarnParams(params, depositParamsSchema)
13488
- * ```
13489
- */
13490
- function assertEarnParams(params, schema) {
13491
- validateWithStateTracking(params, schema, 'earn parameters', ASSERT_EARN_PARAMS_SYMBOL);
13492
- }
13493
-
13494
- function findProvider(context, chain, operation = 'earn') {
13495
- let fallback;
13496
- for (const provider of context.providers) {
13497
- fallback ??= provider;
13498
- if (provider.supportedChains.length === 0)
13499
- continue;
13500
- if (chain === undefined ||
13501
- provider.supportedChains.some((c) => c.chain === chain.chain)) {
13502
- return provider;
13503
- }
13504
- }
13505
- if (fallback === undefined) {
13506
- throw createValidationFailedError('context.providers', [], 'No earn providers configured');
13507
- }
13508
- if (chain !== undefined) {
13509
- throw createUnsupportedEarnRouteError(operation, chain.name);
13510
- }
13511
- return fallback;
13512
- }
13513
-
13514
14162
  /**
13515
14163
  * Format a provider amount object as a human-readable decimal string.
13516
14164
  *
@@ -13653,6 +14301,54 @@ function formatClaimRewardsResult(result) {
13653
14301
  };
13654
14302
  }
13655
14303
 
14304
+ /**
14305
+ * Symbol used to track that assertEarnParams has validated an object.
14306
+ * @internal
14307
+ */
14308
+ const ASSERT_EARN_PARAMS_SYMBOL = Symbol('assertEarnParams');
14309
+ /**
14310
+ * Assert that the provided value conforms to the given earn params schema.
14311
+ *
14312
+ * Validate earn parameters using the provided Zod schema and track
14313
+ * validation state to avoid duplicate checks. Throw a structured
14314
+ * error with detailed validation messages if any parameter is invalid.
14315
+ *
14316
+ * @typeParam T - The expected type after validation
14317
+ * @param params - The earn parameters to validate
14318
+ * @param schema - The Zod schema to validate against
14319
+ * @throws {@link KitError} If the parameters fail validation
14320
+ *
14321
+ * @example
14322
+ * ```typescript
14323
+ * import { assertEarnParams, depositParamsSchema } from '@circle-fin/earn-kit'
14324
+ *
14325
+ * assertEarnParams(params, depositParamsSchema)
14326
+ * ```
14327
+ */
14328
+ function assertEarnParams(params, schema) {
14329
+ validateWithStateTracking(params, schema, 'earn parameters', ASSERT_EARN_PARAMS_SYMBOL);
14330
+ }
14331
+
14332
+ function findProvider(context, chain, operation = 'earn') {
14333
+ let fallback;
14334
+ for (const provider of context.providers) {
14335
+ fallback ??= provider;
14336
+ if (provider.supportedChains.length === 0)
14337
+ continue;
14338
+ if (chain === undefined ||
14339
+ provider.supportedChains.some((c) => c.chain === chain.chain)) {
14340
+ return provider;
14341
+ }
14342
+ }
14343
+ if (fallback === undefined) {
14344
+ throw createValidationFailedError('context.providers', [], 'No earn providers configured');
14345
+ }
14346
+ if (chain !== undefined) {
14347
+ throw createUnsupportedEarnRouteError(operation, chain.name);
14348
+ }
14349
+ return fallback;
14350
+ }
14351
+
13656
14352
  /**
13657
14353
  * Schema for the adapter context within earn operations.
13658
14354
  *
@@ -14302,6 +14998,19 @@ async function getClaimRewardsQuote$1(context, params) {
14302
14998
  return formatClaimRewardsQuoteInfo(result);
14303
14999
  }
14304
15000
 
15001
+ function formatRetryResult(operation, result) {
15002
+ switch (operation) {
15003
+ case 'deposit':
15004
+ case 'withdraw':
15005
+ return result;
15006
+ case 'claimRewards':
15007
+ return formatClaimRewardsResult(result);
15008
+ default: {
15009
+ const exhaustive = operation;
15010
+ throw createValidationFailedError('error.cause.trace.operation', exhaustive, 'EarnKit.retry() does not support this earn operation');
15011
+ }
15012
+ }
15013
+ }
14305
15014
  /**
14306
15015
  * A high-level class-based interface for DeFi lending vault operations.
14307
15016
  *
@@ -14359,6 +15068,12 @@ async function getClaimRewardsQuote$1(context, params) {
14359
15068
  */
14360
15069
  class EarnKit {
14361
15070
  context;
15071
+ /**
15072
+ * Event dispatcher for step-level events emitted during multi-phase earn
15073
+ * operations. Prefer {@link EarnKit.on} / {@link EarnKit.off} over using
15074
+ * this directly.
15075
+ */
15076
+ actionDispatcher;
14362
15077
  /**
14363
15078
  * Create a new EarnKit instance.
14364
15079
  *
@@ -14378,6 +15093,89 @@ class EarnKit {
14378
15093
  */
14379
15094
  constructor(config = {}) {
14380
15095
  this.context = createEarnKitContext(config);
15096
+ this.actionDispatcher = new Actionable();
15097
+ for (const provider of this.context.providers) {
15098
+ provider.registerDispatcher(this.actionDispatcher);
15099
+ }
15100
+ }
15101
+ on(action, handler) {
15102
+ if (action === '*') {
15103
+ this.actionDispatcher.on('*', handler);
15104
+ }
15105
+ else {
15106
+ this.actionDispatcher.on(action, handler);
15107
+ }
15108
+ }
15109
+ off(action, handler) {
15110
+ if (action === '*') {
15111
+ this.actionDispatcher.off('*', handler);
15112
+ }
15113
+ else {
15114
+ this.actionDispatcher.off(action, handler);
15115
+ }
15116
+ }
15117
+ /**
15118
+ * Resume a multi-phase earn operation that previously failed.
15119
+ *
15120
+ * Pass the {@link KitError} caught from a `deposit`, `withdraw`, or
15121
+ * `claimRewards` call. The error carries the original operation, inputs,
15122
+ * and step progress, so the operation can be re-run while skipping phases
15123
+ * that already completed (for example a successful token approval). Use
15124
+ * `isRetryableError(error)` to check whether retrying is worthwhile before
15125
+ * calling this.
15126
+ *
15127
+ * @remarks
15128
+ * Resuming re-fetches execution params and re-submits the `execute`
15129
+ * transaction; the earn service deduplicates execution server-side, which
15130
+ * makes this safe in the common case. But if a prior attempt broadcast the
15131
+ * `execute` transaction and then failed before its receipt was observed,
15132
+ * that transaction may still be in flight when `retry()` re-broadcasts.
15133
+ * Treat `retry()` as best-effort recovery, not an atomic operation.
15134
+ *
15135
+ * @param error - The error caught from a previous multi-phase earn operation.
15136
+ * @returns A promise resolving to the result of the resumed operation.
15137
+ * @throws {@link KitError} If `error` is not a retryable {@link KitError}
15138
+ * carrying earn retry context, names an unknown provider, or the resumed
15139
+ * operation itself fails.
15140
+ *
15141
+ * @example
15142
+ * ```typescript
15143
+ * import { isRetryableError } from '@circle-fin/earn-kit'
15144
+ *
15145
+ * try {
15146
+ * await kit.deposit(params)
15147
+ * } catch (error) {
15148
+ * if (isRetryableError(error)) {
15149
+ * const result = await kit.retry(error)
15150
+ * console.log('recovered, tx:', 'txHash' in result ? result.txHash : undefined)
15151
+ * }
15152
+ * }
15153
+ * ```
15154
+ */
15155
+ async retry(error) {
15156
+ if (!isKitError(error)) {
15157
+ throw createValidationFailedError('error', error, 'EarnKit.retry() requires a KitError thrown by a previous earn operation');
15158
+ }
15159
+ if (!isRetryableError$1(error)) {
15160
+ throw createValidationFailedError('error.recoverability', error.recoverability, 'EarnKit.retry() requires a retryable or resumable error — check isRetryableError(error) first');
15161
+ }
15162
+ const trace = error.cause?.trace;
15163
+ if (!isEarnErrorTrace(trace)) {
15164
+ throw createValidationFailedError('error.cause.trace', trace, 'EarnKit.retry() requires a KitError carrying earn retry context (operation, steps, provider, params)');
15165
+ }
15166
+ const provider = this.context.providers.find((candidate) => candidate.name === trace.provider);
15167
+ if (provider === undefined) {
15168
+ throw createValidationFailedError('error.cause.trace.provider', trace.provider, `No earn provider named "${trace.provider}" is registered with this kit`);
15169
+ }
15170
+ const result = await provider.retry(error);
15171
+ // `provider.retry` returns a flat result union with no compile-time link to
15172
+ // `trace.operation`, so narrow the operation here to select the matching
15173
+ // overload. The result cast in each branch is sound: the provider always
15174
+ // returns the result type corresponding to the resumed operation.
15175
+ if (trace.operation === 'claimRewards') {
15176
+ return formatRetryResult(trace.operation, result);
15177
+ }
15178
+ return formatRetryResult(trace.operation, result);
14381
15179
  }
14382
15180
  /**
14383
15181
  * Return the chains supported by configured earn providers.