diff --git a/packages/sdk/src/__tests__/idempotency.test.ts b/packages/sdk/src/__tests__/idempotency.test.ts new file mode 100644 index 00000000..4dd3ed80 --- /dev/null +++ b/packages/sdk/src/__tests__/idempotency.test.ts @@ -0,0 +1,581 @@ +import { + IdempotencyTracker, + StepRecoveryManager, + createIdempotencyTracker, + createStepRecoveryManager, + generateVaultOperationKey, + createVaultOperationIdempotencyKey, +} from "../idempotency"; +import { IdempotencyWorkflow, VaultOperationRequest } from "../types"; + +describe("IdempotencyTracker", () => { + let tracker: IdempotencyTracker; + + beforeEach(() => { + tracker = createIdempotencyTracker(); + }); + + afterEach(() => { + tracker.destroy(); + }); + + it("starts a new workflow", () => { + const wf = tracker.startWorkflow({ + namespace: "swap", + workflowId: "wf-001", + }); + + expect(wf.idempotencyKey).toMatch(/^wf:swap:/); + expect(wf.namespace).toBe("swap"); + expect(wf.workflowId).toBe("wf-001"); + expect(wf.status).toBe("active"); + expect(wf.steps.size).toBe(0); + }); + + it("returns existing workflow on duplicate start", () => { + const wf1 = tracker.startWorkflow({ + namespace: "swap", + workflowId: "wf-001", + clientRequestId: "req-1", + }); + + const wf2 = tracker.startWorkflow({ + namespace: "swap", + workflowId: "wf-001", + clientRequestId: "req-1", + }); + + expect(wf1.idempotencyKey).toBe(wf2.idempotencyKey); + }); + + it("registers steps on a workflow", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-002", + }); + tracker.registerSteps(wf.idempotencyKey, [ + { stepId: "plan", stepName: "Planning" }, + { stepId: "exec", stepName: "Execution" }, + ]); + + expect(wf.steps.size).toBe(2); + + const planStep = wf.steps.get("plan")!; + expect(planStep.stepName).toBe("Planning"); + expect(planStep.status).toBe("pending"); + expect(planStep.idempotencyKey).toContain(":step:plan"); + }); + + it("returns existing step on duplicate register", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-003", + }); + const s1 = tracker.registerStep(wf.idempotencyKey, "step1", "Step One"); + const s2 = tracker.registerStep(wf.idempotencyKey, "step1", "Step One"); + + expect(s1.idempotencyKey).toBe(s2.idempotencyKey); + expect(s1.stepId).toBe(s2.stepId); + }); + + it("marks step as in_progress and tracks retry count", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-004", + }); + tracker.registerStep(wf.idempotencyKey, "step1", "Step One"); + + const { shouldExecute, step } = tracker.startStep( + wf.idempotencyKey, + "step1" + ); + expect(shouldExecute).toBe(true); + expect(step.status).toBe("in_progress"); + + const { shouldExecute: shouldExecute2, step: step2 } = tracker.startStep( + wf.idempotencyKey, + "step1" + ); + expect(shouldExecute2).toBe(true); + expect(step2.retryCount).toBe(1); + }); + + it("does not re-execute completed steps", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-005", + }); + tracker.registerStep(wf.idempotencyKey, "step1", "Step One"); + tracker.startStep(wf.idempotencyKey, "step1"); + tracker.completeStep(wf.idempotencyKey, "step1", { done: true }); + + const { shouldExecute } = tracker.startStep(wf.idempotencyKey, "step1"); + expect(shouldExecute).toBe(false); + }); + + it("marks step as completed with result", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-006", + }); + tracker.registerStep(wf.idempotencyKey, "step1", "Step One"); + tracker.startStep(wf.idempotencyKey, "step1"); + + const result = { txHash: "abc123" }; + const completed = tracker.completeStep(wf.idempotencyKey, "step1", result); + + expect(completed.status).toBe("completed"); + expect(completed.result).toEqual(result); + }); + + it("marks step as failed with error", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-007", + }); + tracker.registerStep(wf.idempotencyKey, "step1", "Step One"); + tracker.startStep(wf.idempotencyKey, "step1"); + + const failed = tracker.failStep( + wf.idempotencyKey, + "step1", + "Network error" + ); + + expect(failed.status).toBe("failed"); + expect(failed.error).toBe("Network error"); + }); + + it("marks step as skipped", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-008", + }); + tracker.registerStep(wf.idempotencyKey, "step1", "Step One"); + + const skipped = tracker.skipStep(wf.idempotencyKey, "step1"); + expect(skipped.status).toBe("skipped"); + }); + + it("detects completed workflow", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-009", + }); + tracker.registerSteps(wf.idempotencyKey, [ + { stepId: "a", stepName: "A" }, + { stepId: "b", stepName: "B" }, + ]); + + tracker.startStep(wf.idempotencyKey, "a"); + tracker.completeStep(wf.idempotencyKey, "a"); + + expect(wf.status).toBe("active"); + + tracker.startStep(wf.idempotencyKey, "b"); + tracker.completeStep(wf.idempotencyKey, "b"); + + expect(wf.status).toBe("completed"); + }); + + it("detects failed workflow when step fails", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-010", + }); + tracker.registerStep(wf.idempotencyKey, "a", "A"); + tracker.startStep(wf.idempotencyKey, "a"); + tracker.failStep(wf.idempotencyKey, "a", "error"); + + expect(wf.status).toBe("failed"); + }); + + it("resets a failed step to pending", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-011", + }); + tracker.registerStep(wf.idempotencyKey, "a", "A"); + tracker.startStep(wf.idempotencyKey, "a"); + tracker.failStep(wf.idempotencyKey, "a", "error"); + + tracker.resetStep(wf.idempotencyKey, "a"); + + const step = wf.steps.get("a")!; + expect(step.status).toBe("pending"); + expect(step.error).toBeUndefined(); + expect(wf.status).toBe("active"); + }); + + it("resets all failed steps", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-012", + }); + tracker.registerSteps(wf.idempotencyKey, [ + { stepId: "a", stepName: "A" }, + { stepId: "b", stepName: "B" }, + ]); + + tracker.startStep(wf.idempotencyKey, "a"); + tracker.failStep(wf.idempotencyKey, "a", "err-a"); + tracker.startStep(wf.idempotencyKey, "b"); + tracker.completeStep(wf.idempotencyKey, "b"); + + const reset = tracker.resetFailedSteps(wf.idempotencyKey); + expect(reset).toHaveLength(1); + expect(reset[0].stepId).toBe("a"); + expect(reset[0].status).toBe("pending"); + }); + + it("checks step completion", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-013", + }); + tracker.registerStep(wf.idempotencyKey, "a", "A"); + tracker.startStep(wf.idempotencyKey, "a"); + tracker.completeStep(wf.idempotencyKey, "a"); + + expect(tracker.isStepCompleted(wf.idempotencyKey, "a")).toBe(true); + expect(tracker.isStepCompleted(wf.idempotencyKey, "nonexistent")).toBe( + false + ); + }); + + it("retrieves completed, pending, and failed steps", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-014", + }); + tracker.registerSteps(wf.idempotencyKey, [ + { stepId: "a", stepName: "A" }, + { stepId: "b", stepName: "B" }, + ]); + + tracker.startStep(wf.idempotencyKey, "a"); + tracker.completeStep(wf.idempotencyKey, "a"); + tracker.startStep(wf.idempotencyKey, "b"); + tracker.failStep(wf.idempotencyKey, "b", "err"); + + expect(tracker.getCompletedSteps(wf.idempotencyKey)).toHaveLength(1); + expect(tracker.getFailedSteps(wf.idempotencyKey)).toHaveLength(1); + }); + + it("removes a workflow", () => { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "wf-015", + }); + expect(tracker.removeWorkflow(wf.idempotencyKey)).toBe(true); + expect(tracker.getWorkflow(wf.idempotencyKey)).toBeUndefined(); + }); + + it("clears expired workflows", () => { + const shortTracker = new IdempotencyTracker(-1); + shortTracker.startWorkflow({ namespace: "test", workflowId: "wf-expired" }); + const count = shortTracker.clearExpired(); + expect(count).toBe(1); + shortTracker.destroy(); + }); + + it("finds an active workflow", () => { + tracker.startWorkflow({ namespace: "swap", workflowId: "wf-016" }); + + const found = tracker.findActiveWorkflow("swap", "wf-016"); + expect(found).toBeDefined(); + expect(found!.namespace).toBe("swap"); + + const notFound = tracker.findActiveWorkflow("swap", "wf-999"); + expect(notFound).toBeUndefined(); + }); +}); + +describe("StepRecoveryManager", () => { + let tracker: IdempotencyTracker; + let manager: StepRecoveryManager; + + beforeEach(() => { + tracker = createIdempotencyTracker(); + manager = createStepRecoveryManager(); + }); + + afterEach(() => { + tracker.destroy(); + }); + + function createWorkflowWithSteps(): IdempotencyWorkflow { + const wf = tracker.startWorkflow({ + namespace: "test", + workflowId: "recovery-test", + }); + tracker.registerSteps(wf.idempotencyKey, [ + { stepId: "plan", stepName: "Planning" }, + { stepId: "exec", stepName: "Execution" }, + { stepId: "settle", stepName: "Settlement" }, + ]); + return wf; + } + + it("returns a plan to continue when steps are pending", () => { + const wf = createWorkflowWithSteps(); + const plan = manager.getRecoveryPlan(wf); + + expect(plan.canContinue).toBe(true); + expect(plan.stepsToRetry).toEqual([]); + expect(plan.recommendation).toBe("Continue with pending steps."); + }); + + it("returns a plan to retry failed steps", () => { + const wf = createWorkflowWithSteps(); + tracker.startStep(wf.idempotencyKey, "plan"); + tracker.completeStep(wf.idempotencyKey, "plan"); + tracker.startStep(wf.idempotencyKey, "exec"); + tracker.failStep(wf.idempotencyKey, "exec", "timeout"); + + const plan = manager.getRecoveryPlan(wf); + expect(plan.canContinue).toBe(true); + expect(plan.stepsToRetry).toContain("exec"); + expect(plan.stepsToSkip).toContain("plan"); + }); + + it("returns cannot continue when all steps complete", () => { + const wf = createWorkflowWithSteps(); + for (const s of ["plan", "exec", "settle"]) { + tracker.startStep(wf.idempotencyKey, s); + tracker.completeStep(wf.idempotencyKey, s); + } + + const plan = manager.getRecoveryPlan(wf); + expect(plan.canContinue).toBe(false); + expect(plan.recommendation).toContain("done"); + }); + + it("returns cannot continue when non-retryable steps fail", () => { + const wf = createWorkflowWithSteps(); + manager.setRecoveryStrategy("exec", { canRetry: false }); + + tracker.startStep(wf.idempotencyKey, "plan"); + tracker.completeStep(wf.idempotencyKey, "plan"); + tracker.startStep(wf.idempotencyKey, "exec"); + tracker.failStep(wf.idempotencyKey, "exec", "fatal"); + tracker.startStep(wf.idempotencyKey, "settle"); + tracker.completeStep(wf.idempotencyKey, "settle"); + + const plan = manager.getRecoveryPlan(wf); + expect(plan.canContinue).toBe(false); + expect(plan.recommendation).toContain("Manual intervention"); + }); + + it("continues workflow from pending steps", async () => { + const wf = createWorkflowWithSteps(); + + const result = await manager.continueWorkflow( + wf, + async (stepId) => ({ completed: stepId }), + tracker, + { maxRetries: 1, retryDelayMs: 1 } + ); + + expect(result.success).toBe(true); + expect(tracker.getCompletedSteps(wf.idempotencyKey)).toHaveLength(3); + }); + + it("skips completed steps during continuation", async () => { + const wf = createWorkflowWithSteps(); + tracker.startStep(wf.idempotencyKey, "plan"); + tracker.completeStep(wf.idempotencyKey, "plan", { done: true }); + + const executedSteps: string[] = []; + const result = await manager.continueWorkflow( + wf, + async (stepId) => { + executedSteps.push(stepId); + return { completed: stepId }; + }, + tracker, + { maxRetries: 1, retryDelayMs: 1 } + ); + + expect(result.success).toBe(true); + expect(executedSteps).not.toContain("plan"); + expect(executedSteps).toContain("exec"); + expect(executedSteps).toContain("settle"); + }); + + it("retries failed steps during continuation", async () => { + const wf = createWorkflowWithSteps(); + tracker.startStep(wf.idempotencyKey, "plan"); + tracker.completeStep(wf.idempotencyKey, "plan"); + tracker.startStep(wf.idempotencyKey, "exec"); + tracker.failStep(wf.idempotencyKey, "exec", "first attempt failed"); + tracker.startStep(wf.idempotencyKey, "settle"); + tracker.completeStep(wf.idempotencyKey, "settle"); + + const result = await manager.continueWorkflow( + wf, + async (stepId) => ({ completed: stepId }), + tracker, + { maxRetries: 1, retryDelayMs: 1 } + ); + + expect(result.success).toBe(true); + const step = tracker.getStep(wf.idempotencyKey, "exec")!; + expect(step.status).toBe("completed"); + }); + + it("reports failure when step executor throws", async () => { + const wf = createWorkflowWithSteps(); + tracker.startStep(wf.idempotencyKey, "plan"); + tracker.completeStep(wf.idempotencyKey, "plan"); + tracker.startStep(wf.idempotencyKey, "settle"); + tracker.completeStep(wf.idempotencyKey, "settle"); + + const result = await manager.continueWorkflow( + wf, + async (stepId) => { + if (stepId === "exec") { + throw new Error("execution failure"); + } + return { ok: true }; + }, + tracker, + { maxRetries: 1, retryDelayMs: 1 } + ); + + expect(result.success).toBe(false); + expect(result.failedStep).toBe("exec"); + expect(result.error).toBe("execution failure"); + }); + + it("respects maxRetries per strategy", async () => { + const wf = createWorkflowWithSteps(); + let attemptCount = 0; + + tracker.startStep(wf.idempotencyKey, "plan"); + tracker.completeStep(wf.idempotencyKey, "plan"); + tracker.startStep(wf.idempotencyKey, "settle"); + tracker.completeStep(wf.idempotencyKey, "settle"); + + manager.setRecoveryStrategy("exec", { + maxRetries: 2, + retryDelayMs: 1, + }); + + const result = await manager.continueWorkflow( + wf, + async (stepId) => { + attemptCount++; + if (stepId === "exec") { + throw new Error("always fails"); + } + return { ok: true }; + }, + tracker + ); + + expect(result.success).toBe(false); + expect(attemptCount).toBe(3); + }); + + it("invokes onFailure callback when step fails", async () => { + const wf = createWorkflowWithSteps(); + let failureCalled = false; + + tracker.startStep(wf.idempotencyKey, "plan"); + tracker.completeStep(wf.idempotencyKey, "plan"); + tracker.startStep(wf.idempotencyKey, "settle"); + tracker.completeStep(wf.idempotencyKey, "settle"); + + manager.setRecoveryStrategy("exec", { + maxRetries: 0, + retryDelayMs: 1, + onFailure: async () => { + failureCalled = true; + }, + }); + + await manager.continueWorkflow( + wf, + async (stepId) => { + if (stepId === "exec") { + throw new Error("fail"); + } + return { ok: true }; + }, + tracker + ); + + expect(failureCalled).toBe(true); + }); + + it("uses custom shouldRetry to decide retry eligibility", async () => { + const wf = createWorkflowWithSteps(); + let attemptCount = 0; + + tracker.startStep(wf.idempotencyKey, "plan"); + tracker.completeStep(wf.idempotencyKey, "plan"); + tracker.startStep(wf.idempotencyKey, "settle"); + tracker.completeStep(wf.idempotencyKey, "settle"); + + manager.setRecoveryStrategy("exec", { + maxRetries: 5, + retryDelayMs: 1, + shouldRetry: () => false, + }); + + const result = await manager.continueWorkflow( + wf, + async (stepId) => { + attemptCount++; + if (stepId === "exec") { + throw new Error("no retry"); + } + return { ok: true }; + }, + tracker + ); + + expect(result.success).toBe(false); + expect(attemptCount).toBe(1); + }); +}); + +describe("Vault idempotency keys", () => { + const vaultRequest: VaultOperationRequest = { + vaultId: "vault-main", + operationType: "deposit", + asset: "USDC", + amount: "1000", + destination: "GDVAULT", + }; + + it("generates deterministic vault operation keys", () => { + const key1 = generateVaultOperationKey(vaultRequest); + const key2 = generateVaultOperationKey({ ...vaultRequest }); + + expect(key1).toBe(key2); + expect(key1).toMatch(/^vault:deposit:/); + }); + + it("generates different keys for different operations", () => { + const depositKey = generateVaultOperationKey(vaultRequest); + const withdrawKey = generateVaultOperationKey({ + ...vaultRequest, + operationType: "withdraw", + }); + + expect(depositKey).not.toBe(withdrawKey); + }); + + it("creates vault idempotency key with client request ID", () => { + const key = createVaultOperationIdempotencyKey(vaultRequest, "client-abc"); + expect(key).toContain(":client-abc"); + }); + + it("creates vault idempotency key without client request ID", () => { + const key = createVaultOperationIdempotencyKey(vaultRequest); + expect(key).toMatch(/^vault:deposit:/); + expect(key.split(":")).toHaveLength(3); + }); +}); diff --git a/packages/sdk/src/idempotency.ts b/packages/sdk/src/idempotency.ts new file mode 100644 index 00000000..5ec65c02 --- /dev/null +++ b/packages/sdk/src/idempotency.ts @@ -0,0 +1,557 @@ +import { createHash, randomUUID } from "crypto"; +import { + IdempotencyTrackerConfig, + IdempotencyWorkflow, + IdempotencyStep, + StepRecoveryPlan, + StepExecutor, + StepExecutionOptions, + StepRecoveryStrategyFn, + VaultOperationRequest, +} from "./types"; + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => canonicalize(item)); + } + + if (!value || typeof value !== "object") { + return value; + } + + const obj = value as Record; + return Object.keys(obj) + .sort() + .reduce>((acc, key) => { + acc[key] = canonicalize(obj[key]); + return acc; + }, {}); +} + +export class IdempotencyTracker { + private workflows: Map = new Map(); + private cleanupTimer: NodeJS.Timeout | null = null; + + constructor(private defaultTtl: number = 86_400_000) { + this.startCleanup(); + } + + startWorkflow(config: IdempotencyTrackerConfig): IdempotencyWorkflow { + const existing = this.findActiveWorkflow( + config.namespace, + config.workflowId + ); + if (existing) { + return existing; + } + + const idempotencyKey = this.generateWorkflowKey(config); + const workflow: IdempotencyWorkflow = { + idempotencyKey, + namespace: config.namespace, + workflowId: config.workflowId, + status: "active", + steps: new Map(), + createdAt: Date.now(), + lastUpdated: Date.now(), + ttl: config.ttl ?? this.defaultTtl, + }; + + this.workflows.set(idempotencyKey, workflow); + return workflow; + } + + registerStep( + workflowKey: string, + stepId: string, + stepName: string + ): IdempotencyStep { + const workflow = this.workflows.get(workflowKey); + if (!workflow) { + throw new Error(`Workflow not found: ${workflowKey}`); + } + + const existing = workflow.steps.get(stepId); + if (existing) { + return existing; + } + + const step: IdempotencyStep = { + stepId, + stepName, + status: "pending", + idempotencyKey: this.generateStepKey(workflowKey, stepId), + lastUpdated: Date.now(), + retryCount: 0, + }; + + workflow.steps.set(stepId, step); + workflow.lastUpdated = Date.now(); + return step; + } + + registerSteps( + workflowKey: string, + steps: Array<{ stepId: string; stepName: string }> + ): IdempotencyStep[] { + return steps.map((s) => + this.registerStep(workflowKey, s.stepId, s.stepName) + ); + } + + startStep( + workflowKey: string, + stepId: string + ): { shouldExecute: boolean; step: IdempotencyStep } { + const workflow = this.workflows.get(workflowKey); + if (!workflow) { + throw new Error(`Workflow not found: ${workflowKey}`); + } + + const step = workflow.steps.get(stepId); + if (!step) { + throw new Error(`Step not found: ${stepId} in workflow ${workflowKey}`); + } + + if (step.status === "completed") { + return { shouldExecute: false, step }; + } + + if (step.status === "in_progress") { + step.retryCount += 1; + } else { + step.status = "in_progress"; + } + + step.lastUpdated = Date.now(); + workflow.lastUpdated = Date.now(); + return { shouldExecute: true, step }; + } + + completeStep( + workflowKey: string, + stepId: string, + result?: unknown + ): IdempotencyStep { + const workflow = this.workflows.get(workflowKey); + if (!workflow) { + throw new Error(`Workflow not found: ${workflowKey}`); + } + + const step = workflow.steps.get(stepId); + if (!step) { + throw new Error(`Step not found: ${stepId} in workflow ${workflowKey}`); + } + + step.status = "completed"; + step.result = result; + step.lastUpdated = Date.now(); + workflow.lastUpdated = Date.now(); + + this.checkWorkflowCompletion(workflow); + return step; + } + + failStep( + workflowKey: string, + stepId: string, + error: string + ): IdempotencyStep { + const workflow = this.workflows.get(workflowKey); + if (!workflow) { + throw new Error(`Workflow not found: ${workflowKey}`); + } + + const step = workflow.steps.get(stepId); + if (!step) { + throw new Error(`Step not found: ${stepId} in workflow ${workflowKey}`); + } + + step.status = "failed"; + step.error = error; + step.lastUpdated = Date.now(); + workflow.lastUpdated = Date.now(); + + this.checkWorkflowCompletion(workflow); + return step; + } + + skipStep(workflowKey: string, stepId: string): IdempotencyStep { + const workflow = this.workflows.get(workflowKey); + if (!workflow) { + throw new Error(`Workflow not found: ${workflowKey}`); + } + + const step = workflow.steps.get(stepId); + if (!step) { + throw new Error(`Step not found: ${stepId} in workflow ${workflowKey}`); + } + + step.status = "skipped"; + step.lastUpdated = Date.now(); + workflow.lastUpdated = Date.now(); + + this.checkWorkflowCompletion(workflow); + return step; + } + + getWorkflow(workflowKey: string): IdempotencyWorkflow | undefined { + return this.workflows.get(workflowKey); + } + + findActiveWorkflow( + namespace: string, + workflowId: string + ): IdempotencyWorkflow | undefined { + for (const workflow of this.workflows.values()) { + if ( + workflow.namespace === namespace && + workflow.workflowId === workflowId && + workflow.status === "active" + ) { + return workflow; + } + } + return undefined; + } + + isStepCompleted(workflowKey: string, stepId: string): boolean { + const workflow = this.workflows.get(workflowKey); + if (!workflow) return false; + const step = workflow.steps.get(stepId); + return step?.status === "completed" || step?.status === "skipped"; + } + + getCompletedSteps(workflowKey: string): IdempotencyStep[] { + const workflow = this.workflows.get(workflowKey); + if (!workflow) return []; + return Array.from(workflow.steps.values()).filter( + (s) => s.status === "completed" || s.status === "skipped" + ); + } + + getPendingSteps(workflowKey: string): IdempotencyStep[] { + const workflow = this.workflows.get(workflowKey); + if (!workflow) return []; + return Array.from(workflow.steps.values()).filter( + (s) => s.status === "pending" || s.status === "failed" + ); + } + + getFailedSteps(workflowKey: string): IdempotencyStep[] { + const workflow = this.workflows.get(workflowKey); + if (!workflow) return []; + return Array.from(workflow.steps.values()).filter( + (s) => s.status === "failed" + ); + } + + getStep(workflowKey: string, stepId: string): IdempotencyStep | undefined { + const workflow = this.workflows.get(workflowKey); + return workflow?.steps.get(stepId); + } + + resetStep(workflowKey: string, stepId: string): IdempotencyStep { + const workflow = this.workflows.get(workflowKey); + if (!workflow) { + throw new Error(`Workflow not found: ${workflowKey}`); + } + + const step = workflow.steps.get(stepId); + if (!step) { + throw new Error(`Step not found: ${stepId} in workflow ${workflowKey}`); + } + + step.status = "pending"; + step.error = undefined; + step.result = undefined; + step.lastUpdated = Date.now(); + workflow.lastUpdated = Date.now(); + + if (workflow.status === "failed") { + workflow.status = "active"; + } + + return step; + } + + resetFailedSteps(workflowKey: string): IdempotencyStep[] { + const failedSteps = this.getFailedSteps(workflowKey); + return failedSteps.map((s) => this.resetStep(workflowKey, s.stepId)); + } + + removeWorkflow(workflowKey: string): boolean { + return this.workflows.delete(workflowKey); + } + + clearExpired(): number { + const now = Date.now(); + let removed = 0; + for (const [key, workflow] of this.workflows.entries()) { + if (now - workflow.lastUpdated > workflow.ttl) { + this.workflows.delete(key); + removed++; + } + } + return removed; + } + + destroy(): void { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + this.workflows.clear(); + } + + private generateWorkflowKey(config: IdempotencyTrackerConfig): string { + const fingerprint = createHash("sha256") + .update( + JSON.stringify( + canonicalize({ + namespace: config.namespace, + workflowId: config.workflowId, + }) + ) + ) + .digest("hex") + .slice(0, 16); + const requestId = config.clientRequestId ?? randomUUID(); + return `wf:${config.namespace}:${fingerprint}:${requestId}`; + } + + private generateStepKey(workflowKey: string, stepId: string): string { + return `${workflowKey}:step:${stepId}`; + } + + private checkWorkflowCompletion(workflow: IdempotencyWorkflow): void { + const allSteps = Array.from(workflow.steps.values()); + if (allSteps.length === 0) return; + + const allTerminal = allSteps.every( + (s) => + s.status === "completed" || + s.status === "skipped" || + s.status === "failed" + ); + + if (allTerminal) { + const hasFailed = allSteps.some((s) => s.status === "failed"); + workflow.status = hasFailed ? "failed" : "completed"; + workflow.lastUpdated = Date.now(); + } + } + + private startCleanup(): void { + this.cleanupTimer = setInterval(() => { + this.clearExpired(); + }, 60_000); + if (this.cleanupTimer && typeof this.cleanupTimer.unref === "function") { + this.cleanupTimer.unref(); + } + } +} + +export class StepRecoveryManager { + private recoveryStrategies: Map = new Map(); + + setRecoveryStrategy(stepId: string, strategy: StepRecoveryStrategyFn): void { + this.recoveryStrategies.set(stepId, strategy); + } + + removeRecoveryStrategy(stepId: string): void { + this.recoveryStrategies.delete(stepId); + } + + getRecoveryPlan(workflow: IdempotencyWorkflow): StepRecoveryPlan { + const stepsToRetry: string[] = []; + const stepsToSkip: string[] = []; + + for (const [, step] of workflow.steps) { + if (step.status === "failed") { + stepsToRetry.push(step.stepId); + } else if (step.status === "completed" || step.status === "skipped") { + stepsToSkip.push(step.stepId); + } + } + + const hasFailedSteps = stepsToRetry.length > 0; + const hasPendingSteps = this.hasPendingSteps(workflow); + + let canContinue: boolean; + let recommendation: string; + + if (!hasFailedSteps && !hasPendingSteps) { + canContinue = false; + recommendation = "All steps are completed or skipped. Workflow is done."; + } else if (hasFailedSteps && !hasPendingSteps) { + const retryable = stepsToRetry.filter((s) => + this.canRetryStep(workflow, s) + ); + if (retryable.length > 0) { + canContinue = true; + recommendation = `Retry failed steps: ${retryable.join(", ")}`; + } else { + canContinue = false; + recommendation = `Failed steps cannot be retried: ${stepsToRetry.join(", ")}. Manual intervention required.`; + } + } else if (hasPendingSteps && !hasFailedSteps) { + canContinue = true; + recommendation = "Continue with pending steps."; + } else { + canContinue = true; + recommendation = "Retry failed steps and continue with pending steps."; + } + + return { + stepsToRetry, + stepsToSkip, + canContinue, + recommendation, + }; + } + + async continueWorkflow( + workflow: IdempotencyWorkflow, + stepExecutor: StepExecutor, + tracker: IdempotencyTracker, + options?: StepExecutionOptions + ): Promise<{ success: boolean; failedStep?: string; error?: string }> { + const plan = this.getRecoveryPlan(workflow); + + if (!plan.canContinue) { + return { success: false, error: plan.recommendation }; + } + + for (const stepId of plan.stepsToRetry) { + tracker.resetStep(workflow.idempotencyKey, stepId); + } + + const allStepIds = Array.from(workflow.steps.keys()); + for (const stepId of allStepIds) { + const step = workflow.steps.get(stepId)!; + + if (step.status === "completed" || step.status === "skipped") { + continue; + } + + const { shouldExecute } = tracker.startStep( + workflow.idempotencyKey, + stepId + ); + if (!shouldExecute) continue; + + try { + const strategy = this.recoveryStrategies.get(stepId); + const maxRetries = strategy?.maxRetries ?? options?.maxRetries ?? 3; + const retryDelay = + strategy?.retryDelayMs ?? options?.retryDelayMs ?? 1000; + + let lastError: Error | undefined; + let success = false; + let result: unknown; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + await this.delay(retryDelay * attempt); + } + + try { + result = await stepExecutor(stepId, step, attempt); + success = true; + break; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (!this.shouldRetry(stepId, lastError, attempt, maxRetries)) { + break; + } + } + } + + if (success) { + tracker.completeStep(workflow.idempotencyKey, stepId, result); + } else { + const errorMsg = lastError?.message ?? "Step execution failed"; + tracker.failStep(workflow.idempotencyKey, stepId, errorMsg); + if (strategy?.onFailure) { + await strategy.onFailure(step, lastError!); + } + return { success: false, failedStep: stepId, error: errorMsg }; + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + tracker.failStep(workflow.idempotencyKey, stepId, errorMsg); + return { success: false, failedStep: stepId, error: errorMsg }; + } + } + + return { success: true }; + } + + private hasPendingSteps(workflow: IdempotencyWorkflow): boolean { + return Array.from(workflow.steps.values()).some( + (s) => s.status === "pending" || s.status === "in_progress" + ); + } + + private canRetryStep(workflow: IdempotencyWorkflow, stepId: string): boolean { + const strategy = this.recoveryStrategies.get(stepId); + if (!strategy) return true; + return strategy.canRetry !== false; + } + + private shouldRetry( + stepId: string, + error: Error, + attempt: number, + maxRetries: number + ): boolean { + if (attempt >= maxRetries) return false; + const strategy = this.recoveryStrategies.get(stepId); + if (strategy?.shouldRetry) { + return strategy.shouldRetry(error, attempt); + } + return true; + } + + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +export function createIdempotencyTracker(ttlMs?: number): IdempotencyTracker { + return new IdempotencyTracker(ttlMs); +} + +export function createStepRecoveryManager(): StepRecoveryManager { + return new StepRecoveryManager(); +} + +export function generateVaultOperationKey( + request: VaultOperationRequest +): string { + const fingerprint = createHash("sha256") + .update( + JSON.stringify( + canonicalize({ + vaultId: request.vaultId, + operationType: request.operationType, + asset: request.asset, + amount: request.amount, + destination: request.destination, + }) + ) + ) + .digest("hex") + .slice(0, 24); + + return `vault:${request.operationType}:${fingerprint}`; +} + +export function createVaultOperationIdempotencyKey( + request: VaultOperationRequest, + clientRequestId?: string +): string { + const key = generateVaultOperationKey(request); + return clientRequestId ? `${key}:${clientRequestId}` : key; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d3c4ad60..023b4c40 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -21,4 +21,4 @@ export * from "./memoUtils"; export * from "./xdrDecoder"; export * from "./assetCache"; export * from "./networkStatus"; -export * from "./contractClient"; +export * from "./idempotency"; diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index db2f0fc7..6851ac2a 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -1,679 +1,403 @@ -/** Supported blockchains for cross-chain operations */ -export enum ChainId { - BITCOIN = "bitcoin", - STELLAR = "stellar", - STARKNET = "starknet", -} - -/** Represents a user's token balance on a specific chain */ -export interface WalletBalance { - address: string; - symbol: string; - amount: string; - chainId: ChainId; -} - -/** Parameters required to initiate a cross-chain swap */ -export interface CrossChainSwapRequest { - fromChain: ChainId; - toChain: ChainId; - fromToken: string; - toToken: string; - amount: string; - destinationAddress: string; -} - -/** Standard response format from the AI Agent */ -export interface AgentResponse { - success: boolean; - message: string; - data?: unknown; -} - -/** Recovery and cleanup actions available during cross-chain flows */ -export enum RecoveryAction { - RETRY_MINT = "retry_mint", - REFUND_LOCK = "refund_lock", - MANUAL_INTERVENTION = "manual_intervention", -} - -/** Context information required for executing recovery actions */ -export interface RecoveryContext { - // Unique id for the BTC lock transaction - lockTxId: string; - // Lock details (addresses, script, amount, timestamps) - lockDetails?: Record; - // Target mint tx id (if any) - mintTxId?: string; - // Amount and asset info - amount: string; - fromChain: ChainId; - toChain: ChainId; - destinationAddress: string; - metadata?: Record; -} - -/** Outcome of a recovery action */ -export interface RecoveryResult { - actionTaken: RecoveryAction; - success: boolean; - message?: string; - details?: Record; -} - -/** Interface for handling retry operations in the recovery engine */ -export interface RetryHandler { - retryMint: (context: RecoveryContext) => Promise; -} - -/** Interface for handling refund operations in the recovery engine */ -export interface RefundHandler { - refundLock: (context: RecoveryContext) => Promise; -} - -/** Configuration options for the RecoveryEngine */ -export interface RecoveryEngineOptions { - maxRetries?: number; - retryDelayMs?: number; - retryHandler?: RetryHandler; - refundHandler?: RefundHandler; -} - -// ─── Rate limiter types ────────────────────────────────────────────────────── - -/** Configuration for the token bucket rate limiter. */ -export interface RateLimiterConfig { - /** Requests allowed per second (default: 1). */ - requestsPerSecond?: number; - /** Maximum burst size, in requests (default: 1). */ - burstSize?: number; - /** Enable per-endpoint rate limiting (default: false). Useful for tracking separate limits per API endpoint. */ - perEndpoint?: boolean; -} - -/** Rate limit check result. */ -export interface RateLimitCheckResult { - /** Whether the request is allowed under current rate limit. */ - allowed: boolean; - /** Milliseconds to wait before retrying if not allowed (0 if allowed). */ - retryAfterMs: number; - /** Current available tokens in the bucket. */ - tokensAvailable: number; -} - -/** Rate limiter status snapshot. */ -export interface RateLimiterStatus { - /** Total requests checked by this limiter. */ - totalChecks: number; - /** Requests that were rate-limited. */ - limitedRequests: number; - /** Current tokens available globally. */ - tokensAvailable: number; - /** Per-endpoint token availability (if perEndpoint is enabled). */ - perEndpointTokens?: Record; -} - -// ─── Soroban execution logs ────────────────────────────────────────────────── - -export type SorobanNetwork = "testnet" | "mainnet"; - -export interface GetExecutionLogsParams { - /** Transaction hash returned from a Soroban contract call. */ - txHash: string; - network: SorobanNetwork; - /** Override the default RPC URL for the selected network. */ - rpcUrl?: string; -} - -/** A single contract event emitted during transaction execution. */ -export interface ExecutionLogEntry { - /** Position of the event within the transaction result. */ - index: number; - /** Bech32m contract address, or null for system events. */ - contractId: string | null; - /** "contract" | "system" | "diagnostic" */ - type: string; - /** Decoded topic values. */ - topics: unknown[]; - /** Decoded data value. */ - data: unknown; -} - -/** Formatted execution log for a Soroban transaction. */ -export interface ExecutionLog { - txHash: string; - status: "SUCCESS" | "FAILED" | "NOT_FOUND"; - /** Ledger sequence number the transaction was included in, if known. */ - ledger: number | null; - /** Unix timestamp (seconds) of ledger close, if known. */ - createdAt: number | null; - /** Decoded return value of the contract call, if available. */ - returnValue: unknown | null; - /** Contract events emitted during execution. */ - events: ExecutionLogEntry[]; - /** Human-readable error description for FAILED or NOT_FOUND transactions. */ - errorMessage: string | null; -} - -// ─── Soroban event subscription types ──────────────────────────────────────── - -/** Configuration for subscribing to Soroban contract events. */ -export interface EventSubscriptionConfig { - /** Network to subscribe to ("testnet" | "mainnet"). */ - network: "testnet" | "mainnet"; - /** Optional RPC URL override. */ - rpcUrl?: string; - /** Contract ID(s) to subscribe to. */ - contractIds: string[]; - /** Optional topic filter (at least one topic must match). */ - topicFilter?: string[]; - /** Polling interval in milliseconds (default: 5000). Only used in polling mode. */ - pollingIntervalMs?: number; - /** Start from a specific ledger sequence (default: latest). */ - startLedger?: number; -} - -/** A single Soroban contract event. */ -export interface SorobanEvent { - /** Transaction hash that emitted the event. */ - transactionHash: string; - /** Contract ID that emitted the event. */ - contractId: string; - /** Event topics (usually human-readable identifiers). */ - topics: string[]; - /** Event data (typically a serialized value). */ - data: unknown; - /** Ledger sequence the event was included in. */ - ledger: number; - /** Unix timestamp of ledger close. */ - createdAt: number; -} - -/** Callback handler for received events. */ -export type EventHandler = (event: SorobanEvent) => Promise | void; - -/** Callback handler for subscription errors. */ -export type ErrorHandler = (error: Error) => Promise | void; - -/** Event subscription lifecycle. */ -export interface EventSubscription { - /** Stop the subscription and clean up resources. */ - unsubscribe(): Promise; - /** Current subscription status. */ - isActive(): boolean; - /** Last ledger that was checked. */ - getLastLedger(): number | null; -} - -// ─── Network Status types ──────────────────────────────────────────────────── - -/** Configuration for network status checks. */ -export interface NetworkStatusConfig { - /** Network to check ("testnet" | "mainnet"). */ - network: "testnet" | "mainnet"; - /** Optional RPC URL override. */ - rpcUrl?: string; - /** Optional Horizon URL override. */ - horizonUrl?: string; - /** Optional request timeout in milliseconds. */ - timeout?: number; -} - -/** Network health information. */ -export interface NetworkHealth { - /** Whether the network is reachable and responding. */ - isHealthy: boolean; - /** Response time in milliseconds. */ - responseTimeMs: number; - /** Latest ledger sequence. */ - latestLedger: number; - /** Error message if unhealthy. */ - error?: string; -} - -/** Ledger latency information. */ -export interface LedgerLatency { - /** Current ledger sequence. */ - currentLedger: number; - /** Time since last ledger close (seconds). */ - timeSinceLastLedgerSec: number; - /** Average ledger close time (seconds). */ - averageLedgerTimeSec: number; - /** Whether latency is within normal range. */ - isNormal: boolean; -} - -/** Protocol version information. */ -export interface ProtocolVersion { - /** Current protocol version. */ - version: number; - /** Core version string. */ - coreVersion: string; - /** Network passphrase. */ - networkPassphrase: string; -} - -/** Complete network status. */ -export interface NetworkStatus { - /** Network health information. */ - health: NetworkHealth; - /** Ledger latency information. */ - latency: LedgerLatency; - /** Protocol version information. */ - protocol: ProtocolVersion; - /** Timestamp of the check. */ - checkedAt: number; -} -// ─── Stellar Metadata types ────────────────────────────────────────────────── - -/** Configuration for the metadata manager */ -export interface MetadataManagerConfig { - /** Horizon URL (defaults to public network) */ - horizonUrl?: string; - /** Network passphrase (defaults to public network) */ - networkPassphrase?: string; - /** Base fee in stroops */ - baseFee?: number; -} - -/** Parameters for setting metadata on an account */ -export interface MetadataSetParams { - /** Stellar account address */ - accountId: string; - /** Metadata key (alphanumeric, underscores, hyphens; max 128 chars) */ - key: string; - /** Metadata value (max 4KB as string) */ - value: string; - /** Optional metadata type/category */ - type?: string; - /** Optional expiration timestamp (unix seconds) */ - expiresAt?: number; -} - -/** Parameters for retrieving metadata */ -export interface MetadataGetParams { - /** Stellar account address */ - accountId: string; - /** Metadata key to retrieve */ - key: string; -} - -/** Retrieved metadata entry */ -export interface MetadataEntry { - /** Metadata key */ - key: string; - /** Metadata value */ - value: string; - /** Optional metadata type/category */ - type?: string; - /** Creation timestamp (unix seconds) */ - createdAt: number; - /** Last update timestamp (unix seconds) */ - updatedAt: number; - /** Optional expiration timestamp (unix seconds) */ - expiresAt?: number; -} - -/** Response from listing metadata */ -export interface MetadataListResponse { - /** Stellar account address */ - accountId: string; - /** Array of metadata entries */ - metadata: MetadataEntry[]; - /** Total metadata entries for account */ - total: number; - /** Whether more results are available (for pagination) */ - hasMore: boolean; -} -/** Supported blockchains for cross-chain operations */ -export enum ChainId { - BITCOIN = "bitcoin", - STELLAR = "stellar", - STARKNET = "starknet", -} - -/** Represents a user's token balance on a specific chain */ -export interface WalletBalance { - address: string; - symbol: string; - amount: string; - chainId: ChainId; -} - -/** Parameters required to initiate a cross-chain swap */ -export interface CrossChainSwapRequest { - fromChain: ChainId; - toChain: ChainId; - fromToken: string; - toToken: string; - amount: string; - destinationAddress: string; -} - -/** Standard response format from the AI Agent */ -export interface AgentResponse { - success: boolean; - message: string; - data?: unknown; -} - -/** Recovery and cleanup actions available during cross-chain flows */ -export enum RecoveryAction { - RETRY_MINT = "retry_mint", - REFUND_LOCK = "refund_lock", - MANUAL_INTERVENTION = "manual_intervention", -} - -/** Context information required for executing recovery actions */ -export interface RecoveryContext { - // Unique id for the BTC lock transaction - lockTxId: string; - // Lock details (addresses, script, amount, timestamps) - lockDetails?: Record; - // Target mint tx id (if any) - mintTxId?: string; - // Amount and asset info - amount: string; - fromChain: ChainId; - toChain: ChainId; - destinationAddress: string; - metadata?: Record; -} - -/** Outcome of a recovery action */ -export interface RecoveryResult { - actionTaken: RecoveryAction; - success: boolean; - message?: string; - details?: Record; -} - -/** Interface for handling retry operations in the recovery engine */ -export interface RetryHandler { - retryMint: (context: RecoveryContext) => Promise; -} - -/** Interface for handling refund operations in the recovery engine */ -export interface RefundHandler { - refundLock: (context: RecoveryContext) => Promise; -} - -/** Configuration options for the RecoveryEngine */ -export interface RecoveryEngineOptions { - maxRetries?: number; - retryDelayMs?: number; - retryHandler?: RetryHandler; - refundHandler?: RefundHandler; -} - -// ─── Rate limiter types ────────────────────────────────────────────────────── - -/** Configuration for the token bucket rate limiter. */ -export interface RateLimiterConfig { - /** Requests allowed per second (default: 1). */ - requestsPerSecond?: number; - /** Maximum burst size, in requests (default: 1). */ - burstSize?: number; - /** Enable per-endpoint rate limiting (default: false). Useful for tracking separate limits per API endpoint. */ - perEndpoint?: boolean; -} - -/** Rate limit check result. */ -export interface RateLimitCheckResult { - /** Whether the request is allowed under current rate limit. */ - allowed: boolean; - /** Milliseconds to wait before retrying if not allowed (0 if allowed). */ - retryAfterMs: number; - /** Current available tokens in the bucket. */ - tokensAvailable: number; -} - -/** Rate limiter status snapshot. */ -export interface RateLimiterStatus { - /** Total requests checked by this limiter. */ - totalChecks: number; - /** Requests that were rate-limited. */ - limitedRequests: number; - /** Current tokens available globally. */ - tokensAvailable: number; - /** Per-endpoint token availability (if perEndpoint is enabled). */ - perEndpointTokens?: Record; -} - -// ─── Soroban execution logs ────────────────────────────────────────────────── - -export type SorobanNetwork = "testnet" | "mainnet"; - -export interface GetExecutionLogsParams { - /** Transaction hash returned from a Soroban contract call. */ - txHash: string; - network: SorobanNetwork; - /** Override the default RPC URL for the selected network. */ - rpcUrl?: string; -} - -/** A single contract event emitted during transaction execution. */ -export interface ExecutionLogEntry { - /** Position of the event within the transaction result. */ - index: number; - /** Bech32m contract address, or null for system events. */ - contractId: string | null; - /** "contract" | "system" | "diagnostic" */ - type: string; - /** Decoded topic values. */ - topics: unknown[]; - /** Decoded data value. */ - data: unknown; -} - -/** Formatted execution log for a Soroban transaction. */ -export interface ExecutionLog { - txHash: string; - status: "SUCCESS" | "FAILED" | "NOT_FOUND"; - /** Ledger sequence number the transaction was included in, if known. */ - ledger: number | null; - /** Unix timestamp (seconds) of ledger close, if known. */ - createdAt: number | null; - /** Decoded return value of the contract call, if available. */ - returnValue: unknown | null; - /** Contract events emitted during execution. */ - events: ExecutionLogEntry[]; - /** Human-readable error description for FAILED or NOT_FOUND transactions. */ - errorMessage: string | null; -} - -// ─── Soroban event subscription types ──────────────────────────────────────── - -/** Configuration for subscribing to Soroban contract events. */ -export interface EventSubscriptionConfig { - /** Network to subscribe to ("testnet" | "mainnet"). */ - network: "testnet" | "mainnet"; - /** Optional RPC URL override. */ - rpcUrl?: string; - /** Contract ID(s) to subscribe to. */ - contractIds: string[]; - /** Optional topic filter (at least one topic must match). */ - topicFilter?: string[]; - /** Polling interval in milliseconds (default: 5000). Only used in polling mode. */ - pollingIntervalMs?: number; - /** Start from a specific ledger sequence (default: latest). */ - startLedger?: number; -} - -/** A single Soroban contract event. */ -export interface SorobanEvent { - /** Transaction hash that emitted the event. */ - transactionHash: string; - /** Contract ID that emitted the event. */ - contractId: string; - /** Event topics (usually human-readable identifiers). */ - topics: string[]; - /** Event data (typically a serialized value). */ - data: unknown; - /** Ledger sequence the event was included in. */ - ledger: number; - /** Unix timestamp of ledger close. */ - createdAt: number; -} - -/** Callback handler for received events. */ -export type EventHandler = (event: SorobanEvent) => Promise | void; - -/** Callback handler for subscription errors. */ -export type ErrorHandler = (error: Error) => Promise | void; - -/** Event subscription lifecycle. */ -export interface EventSubscription { - /** Stop the subscription and clean up resources. */ - unsubscribe(): Promise; - /** Current subscription status. */ - isActive(): boolean; - /** Last ledger that was checked. */ - getLastLedger(): number | null; -} - -// ─── Network Status types ──────────────────────────────────────────────────── - -/** Configuration for network status checks. */ -export interface NetworkStatusConfig { - /** Network to check ("testnet" | "mainnet"). */ - network: "testnet" | "mainnet"; - /** Optional RPC URL override. */ - rpcUrl?: string; - /** Optional Horizon URL override. */ - horizonUrl?: string; - /** Optional request timeout in milliseconds. */ - timeout?: number; -} - -/** Network health information. */ -export interface NetworkHealth { - /** Whether the network is reachable and responding. */ - isHealthy: boolean; - /** Response time in milliseconds. */ - responseTimeMs: number; - /** Latest ledger sequence. */ - latestLedger: number; - /** Error message if unhealthy. */ - error?: string; -} - -/** Ledger latency information. */ -export interface LedgerLatency { - /** Current ledger sequence. */ - currentLedger: number; - /** Time since last ledger close (seconds). */ - timeSinceLastLedgerSec: number; - /** Average ledger close time (seconds). */ - averageLedgerTimeSec: number; - /** Whether latency is within normal range. */ - isNormal: boolean; -} - -/** Protocol version information. */ -export interface ProtocolVersion { - /** Current protocol version. */ - version: number; - /** Core version string. */ - coreVersion: string; - /** Network passphrase. */ - networkPassphrase: string; -} - -/** Complete network status. */ -export interface NetworkStatus { - /** Network health information. */ - health: NetworkHealth; - /** Ledger latency information. */ - latency: LedgerLatency; - /** Protocol version information. */ - protocol: ProtocolVersion; - /** Timestamp of the check. */ - checkedAt: number; -} -// ─── Stellar Metadata types ────────────────────────────────────────────────── - -/** Configuration for the metadata manager */ -export interface MetadataManagerConfig { - /** Horizon URL (defaults to public network) */ - horizonUrl?: string; - /** Network passphrase (defaults to public network) */ - networkPassphrase?: string; - /** Base fee in stroops */ - baseFee?: number; -} - -/** Parameters for setting metadata on an account */ -export interface MetadataSetParams { - /** Stellar account address */ - accountId: string; - /** Metadata key (alphanumeric, underscores, hyphens; max 128 chars) */ - key: string; - /** Metadata value (max 4KB as string) */ - value: string; - /** Optional metadata type/category */ - type?: string; - /** Optional expiration timestamp (unix seconds) */ - expiresAt?: number; -} - -/** Parameters for retrieving metadata */ -export interface MetadataGetParams { - /** Stellar account address */ - accountId: string; - /** Metadata key to retrieve */ - key: string; -} - -/** Retrieved metadata entry */ -export interface MetadataEntry { - /** Metadata key */ - key: string; - /** Metadata value */ - value: string; - /** Optional metadata type/category */ - type?: string; - /** Creation timestamp (unix seconds) */ - createdAt: number; - /** Last update timestamp (unix seconds) */ - updatedAt: number; - /** Optional expiration timestamp (unix seconds) */ - expiresAt?: number; -} - -/** Response from listing metadata */ -export interface MetadataListResponse { - /** Stellar account address */ - accountId: string; - /** Array of metadata entries */ - metadata: MetadataEntry[]; - /** Total metadata entries for account */ - total: number; - /** Whether more results are available (for pagination) */ - hasMore: boolean; -} - -/** Capability identifier for a contract */ -export type ContractCapability = string; - -/** Metadata for a specific contract version */ -export interface ContractVersionMetadata { - /** Semantic version, e.g., "1.2.3" */ - version: string; - /** Optional description of this version */ - description?: string; - /** Capabilities supported by this version */ - capabilities: ContractCapability[]; - /** Arbitrary compatibility metadata */ - metadata?: Record; -} - -/** Compatibility metadata for a contract across versions */ -export interface ContractCompatibilityMetadata { - /** Contract identifier (Stellar contract ID) */ - contractId: string; - /** List of supported versions */ - versions: ContractVersionMetadata[]; - /** Default version to use when none specified */ - defaultVersion?: string; -} +/** Supported blockchains for cross-chain operations */ +export enum ChainId { + BITCOIN = "bitcoin", + STELLAR = "stellar", + STARKNET = "starknet", +} + +/** Represents a user's token balance on a specific chain */ +export interface WalletBalance { + address: string; + symbol: string; + amount: string; + chainId: ChainId; +} + +/** Parameters required to initiate a cross-chain swap */ +export interface CrossChainSwapRequest { + fromChain: ChainId; + toChain: ChainId; + fromToken: string; + toToken: string; + amount: string; + destinationAddress: string; +} + +/** Standard response format from the AI Agent */ +export interface AgentResponse { + success: boolean; + message: string; + data?: unknown; +} + +/** Recovery and cleanup actions available during cross-chain flows */ +export enum RecoveryAction { + RETRY_MINT = "retry_mint", + REFUND_LOCK = "refund_lock", + MANUAL_INTERVENTION = "manual_intervention", +} + +/** Context information required for executing recovery actions */ +export interface RecoveryContext { + // Unique id for the BTC lock transaction + lockTxId: string; + // Lock details (addresses, script, amount, timestamps) + lockDetails?: Record; + // Target mint tx id (if any) + mintTxId?: string; + // Amount and asset info + amount: string; + fromChain: ChainId; + toChain: ChainId; + destinationAddress: string; + metadata?: Record; +} + +/** Outcome of a recovery action */ +export interface RecoveryResult { + actionTaken: RecoveryAction; + success: boolean; + message?: string; + details?: Record; +} + +/** Interface for handling retry operations in the recovery engine */ +export interface RetryHandler { + retryMint: (context: RecoveryContext) => Promise; +} + +/** Interface for handling refund operations in the recovery engine */ +export interface RefundHandler { + refundLock: (context: RecoveryContext) => Promise; +} + +/** Configuration options for the RecoveryEngine */ +export interface RecoveryEngineOptions { + maxRetries?: number; + retryDelayMs?: number; + retryHandler?: RetryHandler; + refundHandler?: RefundHandler; +} + +// ─── Rate limiter types ────────────────────────────────────────────────────── + +/** Configuration for the token bucket rate limiter. */ +export interface RateLimiterConfig { + /** Requests allowed per second (default: 1). */ + requestsPerSecond?: number; + /** Maximum burst size, in requests (default: 1). */ + burstSize?: number; + /** Enable per-endpoint rate limiting (default: false). Useful for tracking separate limits per API endpoint. */ + perEndpoint?: boolean; +} + +/** Rate limit check result. */ +export interface RateLimitCheckResult { + /** Whether the request is allowed under current rate limit. */ + allowed: boolean; + /** Milliseconds to wait before retrying if not allowed (0 if allowed). */ + retryAfterMs: number; + /** Current available tokens in the bucket. */ + tokensAvailable: number; +} + +/** Rate limiter status snapshot. */ +export interface RateLimiterStatus { + /** Total requests checked by this limiter. */ + totalChecks: number; + /** Requests that were rate-limited. */ + limitedRequests: number; + /** Current tokens available globally. */ + tokensAvailable: number; + /** Per-endpoint token availability (if perEndpoint is enabled). */ + perEndpointTokens?: Record; +} + +// ─── Soroban execution logs ────────────────────────────────────────────────── + +export type SorobanNetwork = "testnet" | "mainnet"; + +export interface GetExecutionLogsParams { + /** Transaction hash returned from a Soroban contract call. */ + txHash: string; + network: SorobanNetwork; + /** Override the default RPC URL for the selected network. */ + rpcUrl?: string; +} + +/** A single contract event emitted during transaction execution. */ +export interface ExecutionLogEntry { + /** Position of the event within the transaction result. */ + index: number; + /** Bech32m contract address, or null for system events. */ + contractId: string | null; + /** "contract" | "system" | "diagnostic" */ + type: string; + /** Decoded topic values. */ + topics: unknown[]; + /** Decoded data value. */ + data: unknown; +} + +/** Formatted execution log for a Soroban transaction. */ +export interface ExecutionLog { + txHash: string; + status: "SUCCESS" | "FAILED" | "NOT_FOUND"; + /** Ledger sequence number the transaction was included in, if known. */ + ledger: number | null; + /** Unix timestamp (seconds) of ledger close, if known. */ + createdAt: number | null; + /** Decoded return value of the contract call, if available. */ + returnValue: unknown | null; + /** Contract events emitted during execution. */ + events: ExecutionLogEntry[]; + /** Human-readable error description for FAILED or NOT_FOUND transactions. */ + errorMessage: string | null; +} + +// ─── Soroban event subscription types ──────────────────────────────────────── + +/** Configuration for subscribing to Soroban contract events. */ +export interface EventSubscriptionConfig { + /** Network to subscribe to ("testnet" | "mainnet"). */ + network: "testnet" | "mainnet"; + /** Optional RPC URL override. */ + rpcUrl?: string; + /** Contract ID(s) to subscribe to. */ + contractIds: string[]; + /** Optional topic filter (at least one topic must match). */ + topicFilter?: string[]; + /** Polling interval in milliseconds (default: 5000). Only used in polling mode. */ + pollingIntervalMs?: number; + /** Start from a specific ledger sequence (default: latest). */ + startLedger?: number; +} + +/** A single Soroban contract event. */ +export interface SorobanEvent { + /** Transaction hash that emitted the event. */ + transactionHash: string; + /** Contract ID that emitted the event. */ + contractId: string; + /** Event topics (usually human-readable identifiers). */ + topics: string[]; + /** Event data (typically a serialized value). */ + data: unknown; + /** Ledger sequence the event was included in. */ + ledger: number; + /** Unix timestamp of ledger close. */ + createdAt: number; +} + +/** Callback handler for received events. */ +export type EventHandler = (event: SorobanEvent) => Promise | void; + +/** Callback handler for subscription errors. */ +export type ErrorHandler = (error: Error) => Promise | void; + +/** Event subscription lifecycle. */ +export interface EventSubscription { + /** Stop the subscription and clean up resources. */ + unsubscribe(): Promise; + /** Current subscription status. */ + isActive(): boolean; + /** Last ledger that was checked. */ + getLastLedger(): number | null; +} + +// ─── Network Status types ──────────────────────────────────────────────────── + +/** Configuration for network status checks. */ +export interface NetworkStatusConfig { + /** Network to check ("testnet" | "mainnet"). */ + network: "testnet" | "mainnet"; + /** Optional RPC URL override. */ + rpcUrl?: string; + /** Optional Horizon URL override. */ + horizonUrl?: string; + /** Optional request timeout in milliseconds. */ + timeout?: number; +} + +/** Network health information. */ +export interface NetworkHealth { + /** Whether the network is reachable and responding. */ + isHealthy: boolean; + /** Response time in milliseconds. */ + responseTimeMs: number; + /** Latest ledger sequence. */ + latestLedger: number; + /** Error message if unhealthy. */ + error?: string; +} + +/** Ledger latency information. */ +export interface LedgerLatency { + /** Current ledger sequence. */ + currentLedger: number; + /** Time since last ledger close (seconds). */ + timeSinceLastLedgerSec: number; + /** Average ledger close time (seconds). */ + averageLedgerTimeSec: number; + /** Whether latency is within normal range. */ + isNormal: boolean; +} + +/** Protocol version information. */ +export interface ProtocolVersion { + /** Current protocol version. */ + version: number; + /** Core version string. */ + coreVersion: string; + /** Network passphrase. */ + networkPassphrase: string; +} + +/** Complete network status. */ +export interface NetworkStatus { + /** Network health information. */ + health: NetworkHealth; + /** Ledger latency information. */ + latency: LedgerLatency; + /** Protocol version information. */ + protocol: ProtocolVersion; + /** Timestamp of the check. */ + checkedAt: number; +} +// ─── Stellar Metadata types ────────────────────────────────────────────────── + +/** Configuration for the metadata manager */ +export interface MetadataManagerConfig { + /** Horizon URL (defaults to public network) */ + horizonUrl?: string; + /** Network passphrase (defaults to public network) */ + networkPassphrase?: string; + /** Base fee in stroops */ + baseFee?: number; +} + +/** Parameters for setting metadata on an account */ +export interface MetadataSetParams { + /** Stellar account address */ + accountId: string; + /** Metadata key (alphanumeric, underscores, hyphens; max 128 chars) */ + key: string; + /** Metadata value (max 4KB as string) */ + value: string; + /** Optional metadata type/category */ + type?: string; + /** Optional expiration timestamp (unix seconds) */ + expiresAt?: number; +} + +/** Parameters for retrieving metadata */ +export interface MetadataGetParams { + /** Stellar account address */ + accountId: string; + /** Metadata key to retrieve */ + key: string; +} + +/** Retrieved metadata entry */ +export interface MetadataEntry { + /** Metadata key */ + key: string; + /** Metadata value */ + value: string; + /** Optional metadata type/category */ + type?: string; + /** Creation timestamp (unix seconds) */ + createdAt: number; + /** Last update timestamp (unix seconds) */ + updatedAt: number; + /** Optional expiration timestamp (unix seconds) */ + expiresAt?: number; +} + +/** Response from listing metadata */ +export interface MetadataListResponse { + /** Stellar account address */ + accountId: string; + /** Array of metadata entries */ + metadata: MetadataEntry[]; + /** Total metadata entries for account */ + total: number; + /** Whether more results are available (for pagination) */ + hasMore: boolean; +} + +// ─── Multi-step Idempotency types ────────────────────────────────────────── + +export type IdempotencyStepStatus = + | "pending" + | "in_progress" + | "completed" + | "failed" + | "skipped"; + +export interface IdempotencyStep { + stepId: string; + stepName: string; + status: IdempotencyStepStatus; + idempotencyKey: string; + result?: unknown; + error?: string; + lastUpdated: number; + retryCount: number; +} + +export interface IdempotencyTrackerConfig { + namespace: string; + workflowId: string; + clientRequestId?: string; + ttl?: number; +} + +export interface IdempotencyWorkflow { + idempotencyKey: string; + namespace: string; + workflowId: string; + status: "active" | "completed" | "failed"; + steps: Map; + metadata?: Record; + createdAt: number; + lastUpdated: number; + ttl: number; +} + +export interface StepExecutionOptions { + skipIfCompleted?: boolean; + maxRetries?: number; + retryDelayMs?: number; + timeoutMs?: number; +} + +export interface StepRecoveryPlan { + stepsToRetry: string[]; + stepsToSkip: string[]; + canContinue: boolean; + recommendation: string; +} + +export type StepExecutor = ( + stepId: string, + step: IdempotencyStep, + attempt: number +) => Promise; + +export interface StepRecoveryStrategyFn { + maxRetries?: number; + retryDelayMs?: number; + canRetry?: boolean; + shouldRetry?: (error: Error, attempt: number) => boolean; + onFailure?: (step: IdempotencyStep, error: Error) => Promise; +} + +export interface VaultOperationRequest { + vaultId: string; + operationType: string; + asset: string; + amount: string; + destination?: string; + metadata?: Record; +}