From 7e6c52569e1df6257c122044184c35922f738c7e Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Tue, 13 Jan 2026 17:04:21 -0800 Subject: [PATCH] Fix proposal status when Safe execution fails Validate Safe receipts (ExecutionSuccess/ExecutionFailure) before marking action proposals executed, persist tx hashes on failures, and add a backfill script. --- .../backfill-action-proposals-executed.ts | 271 ++++++++ .../components/dashboard/unified-activity.tsx | 28 +- .../server/routers/action-proposals-router.ts | 612 ++++++++++++++---- 3 files changed, 770 insertions(+), 141 deletions(-) create mode 100644 packages/web/scripts/backfill-action-proposals-executed.ts diff --git a/packages/web/scripts/backfill-action-proposals-executed.ts b/packages/web/scripts/backfill-action-proposals-executed.ts new file mode 100644 index 00000000..502dd7d4 --- /dev/null +++ b/packages/web/scripts/backfill-action-proposals-executed.ts @@ -0,0 +1,271 @@ +import { db } from '@/db'; +import { actionProposals } from '@/db/schema'; +import { and, eq, isNotNull } from 'drizzle-orm'; +import { + decodeEventLog, + getEventSelector, + parseAbiItem, + type Address, + type Hex, + type TransactionReceipt, +} from 'viem'; +import { + isSupportedChain, + SUPPORTED_CHAINS, + type SupportedChainId, +} from '@/lib/constants/chains'; +import { getRPCManager } from '@/lib/multi-chain-rpc'; + +type VaultPayload = Record; + +function resolvePayloadChainId(payload: VaultPayload): SupportedChainId { + const chainIdRaw = + (payload.chainId as number | string | undefined) ?? + (payload.chain_id as number | string | undefined); + + const chainId = + typeof chainIdRaw === 'number' + ? chainIdRaw + : typeof chainIdRaw === 'string' + ? Number(chainIdRaw) + : null; + + if (chainId && isSupportedChain(chainId)) { + return chainId; + } + + return SUPPORTED_CHAINS.BASE; +} + +function isHexHash(value: string): value is Hex { + return /^0x[a-fA-F0-9]{64}$/.test(value); +} + +const SAFE_EXECUTION_SUCCESS_EVENT = parseAbiItem( + 'event ExecutionSuccess(bytes32 txHash,uint256 payment)', +); + +const SAFE_EXECUTION_FAILURE_EVENT = parseAbiItem( + 'event ExecutionFailure(bytes32 txHash,uint256 payment)', +); + +const SAFE_EXECUTION_SUCCESS_SELECTOR = getEventSelector( + SAFE_EXECUTION_SUCCESS_EVENT, +); + +const SAFE_EXECUTION_FAILURE_SELECTOR = getEventSelector( + SAFE_EXECUTION_FAILURE_EVENT, +); + +function findSafeExecutionEvent(receipt: TransactionReceipt): { + status: 'success' | 'failure'; + safeTxHash: Hex; + safeAddress: Address; +} | null { + for (const log of receipt.logs) { + if (log.topics[0] === SAFE_EXECUTION_FAILURE_SELECTOR) { + const decoded = decodeEventLog({ + abi: [SAFE_EXECUTION_FAILURE_EVENT], + data: log.data, + topics: log.topics, + }); + if (decoded.eventName === 'ExecutionFailure') { + return { + status: 'failure', + safeTxHash: decoded.args.txHash as Hex, + safeAddress: log.address as Address, + }; + } + } + } + + for (const log of receipt.logs) { + if (log.topics[0] === SAFE_EXECUTION_SUCCESS_SELECTOR) { + const decoded = decodeEventLog({ + abi: [SAFE_EXECUTION_SUCCESS_EVENT], + data: log.data, + topics: log.topics, + }); + if (decoded.eventName === 'ExecutionSuccess') { + return { + status: 'success', + safeTxHash: decoded.args.txHash as Hex, + safeAddress: log.address as Address, + }; + } + } + } + + return null; +} + +function parseArgs(argv: string[]) { + const args = argv.slice(2); + const dryRun = args.includes('--dry-run'); + + const get = (flag: string): string | null => { + const index = args.indexOf(flag); + if (index === -1) return null; + return args[index + 1] ?? null; + }; + + const limitRaw = get('--limit'); + const limit = limitRaw ? Number(limitRaw) : 500; + const workspaceId = get('--workspace-id'); + + return { + dryRun, + limit: Number.isFinite(limit) ? limit : 500, + workspaceId, + }; +} + +function formatErrorMessage(error: unknown): string { + if (error instanceof Error) { + return (error as { shortMessage?: string }).shortMessage ?? error.message; + } + + return String(error); +} + +async function main() { + const { dryRun, limit, workspaceId } = parseArgs(process.argv); + + console.log('[backfill] Starting action_proposals execution verification', { + dryRun, + limit, + workspaceId: workspaceId ?? null, + }); + + const conditions = [ + eq(actionProposals.status, 'executed'), + isNotNull(actionProposals.txHash), + ]; + + if (workspaceId) { + conditions.push(eq(actionProposals.workspaceId, workspaceId)); + } + + const proposals = await db.query.actionProposals.findMany({ + where: and(...conditions), + limit, + orderBy: (table, { desc }) => [desc(table.updatedAt)], + }); + + console.log(`[backfill] Loaded ${proposals.length} executed proposals`); + + const rpcManager = getRPCManager(); + + let checked = 0; + let reclassified = 0; + let skipped = 0; + + for (const proposal of proposals) { + checked += 1; + + const storedHash = proposal.txHash; + if (!storedHash || !isHexHash(storedHash)) { + skipped += 1; + console.warn('[backfill] Skipping proposal with invalid txHash', { + proposalId: proposal.id, + txHash: storedHash, + }); + continue; + } + + const chainId = resolvePayloadChainId(proposal.payload as VaultPayload); + const publicClient = rpcManager.getClient(chainId); + + let receipt: TransactionReceipt; + try { + receipt = await publicClient.getTransactionReceipt({ + hash: storedHash, + }); + } catch (error) { + skipped += 1; + console.warn('[backfill] Receipt not found', { + proposalId: proposal.id, + chainId, + txHash: storedHash, + error: formatErrorMessage(error), + }); + continue; + } + + if (receipt.status !== 'success') { + const reason = `Transaction ${storedHash} reverted on-chain.`; + console.warn('[backfill] Reclassifying reverted transaction', { + proposalId: proposal.id, + chainId, + txHash: storedHash, + blockNumber: receipt.blockNumber?.toString(), + }); + + if (!dryRun) { + const updatedMessage = proposal.proposalMessage + ? `${proposal.proposalMessage}\n\nExecution failed: ${reason}` + : reason; + + await db + .update(actionProposals) + .set({ + status: 'failed', + proposalMessage: updatedMessage, + }) + .where(eq(actionProposals.id, proposal.id)); + } + + reclassified += 1; + continue; + } + + const safeExecution = findSafeExecutionEvent(receipt); + if (!safeExecution) { + skipped += 1; + console.warn('[backfill] Missing Safe execution logs', { + proposalId: proposal.id, + chainId, + txHash: storedHash, + }); + continue; + } + + if (safeExecution.status === 'success') { + continue; + } + + const reason = `Safe execution failed (ExecutionFailure). safeTxHash: ${safeExecution.safeTxHash}`; + console.warn('[backfill] Reclassifying safe ExecutionFailure', { + proposalId: proposal.id, + chainId, + txHash: storedHash, + safeAddress: safeExecution.safeAddress, + safeTxHash: safeExecution.safeTxHash, + }); + + if (!dryRun) { + const updatedMessage = proposal.proposalMessage + ? `${proposal.proposalMessage}\n\nExecution failed: ${reason}` + : reason; + + await db + .update(actionProposals) + .set({ + status: 'failed', + proposalMessage: updatedMessage, + }) + .where(eq(actionProposals.id, proposal.id)); + } + + reclassified += 1; + } + + console.log('[backfill] Completed', { checked, reclassified, skipped }); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error('[backfill] Failed', error); + process.exit(1); + }); diff --git a/packages/web/src/app/(authenticated)/dashboard/(bank)/components/dashboard/unified-activity.tsx b/packages/web/src/app/(authenticated)/dashboard/(bank)/components/dashboard/unified-activity.tsx index d64d29c3..e4e5d4ce 100644 --- a/packages/web/src/app/(authenticated)/dashboard/(bank)/components/dashboard/unified-activity.tsx +++ b/packages/web/src/app/(authenticated)/dashboard/(bank)/components/dashboard/unified-activity.tsx @@ -1103,6 +1103,7 @@ export function UnifiedActivity() { const actionId = tx.proposalId; setActionPendingIds((prev) => new Set(prev).add(actionId)); + let relayTxHash: string | undefined; try { const amount = BigInt(amountBaseUnits); @@ -1189,12 +1190,30 @@ export function UnifiedActivity() { throw new Error('No executable transactions built for proposal.'); } - const txHash = await sendWithRelay(transactions); - await markProposalExecuted.mutateAsync({ + relayTxHash = await sendWithRelay(transactions); + + let result = await markProposalExecuted.mutateAsync({ id: tx.proposalId, - txHash, + txHash: relayTxHash, }); - toast.success('Proposal executed'); + + let attempts = 0; + while ('pending' in result && result.pending && attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 3_000)); + result = await markProposalExecuted.mutateAsync({ + id: tx.proposalId, + txHash: relayTxHash, + }); + attempts += 1; + } + + if (result.status === 'executed') { + toast.success('Proposal executed'); + } else if (result.status === 'failed') { + toast.error(result.reason ?? 'Proposal failed'); + } else { + toast('Transaction submitted; awaiting confirmation.'); + } } catch (err) { const message = err instanceof Error ? err.message : 'Failed to execute proposal'; @@ -1202,6 +1221,7 @@ export function UnifiedActivity() { await markProposalFailed.mutateAsync({ id: tx.proposalId, reason: message, + ...(relayTxHash ? { txHash: relayTxHash } : null), }); } finally { setActionPendingIds((prev) => { diff --git a/packages/web/src/server/routers/action-proposals-router.ts b/packages/web/src/server/routers/action-proposals-router.ts index 4f300500..9277e637 100644 --- a/packages/web/src/server/routers/action-proposals-router.ts +++ b/packages/web/src/server/routers/action-proposals-router.ts @@ -4,7 +4,20 @@ import { router, protectedProcedure } from '../create-router'; import { db } from '@/db'; import { actionProposals } from '@/db/schema'; import { and, eq, inArray } from 'drizzle-orm'; -import { type Address } from 'viem'; +import { + decodeEventLog, + getEventSelector, + parseAbiItem, + type Address, + type Hex, + type TransactionReceipt, +} from 'viem'; +import { + isSupportedChain, + SUPPORTED_CHAINS, + type SupportedChainId, +} from '@/lib/constants/chains'; +import { getRPCManager } from '@/lib/multi-chain-rpc'; import { dispatchWebhookEvent, logAuditEvent, @@ -77,6 +90,329 @@ async function resolveVaultId(payload: VaultPayload) { return vault?.id ?? null; } +function resolvePayloadChainId(payload: VaultPayload): SupportedChainId { + const chainIdRaw = + (payload.chainId as number | string | undefined) ?? + (payload.chain_id as number | string | undefined); + + const chainId = + typeof chainIdRaw === 'number' + ? chainIdRaw + : typeof chainIdRaw === 'string' + ? Number(chainIdRaw) + : null; + + if (chainId && isSupportedChain(chainId)) { + return chainId; + } + + return SUPPORTED_CHAINS.BASE; +} + +const ENTRY_POINT_BY_CHAIN: Partial> = { + [SUPPORTED_CHAINS.BASE]: '0x0576a174D229E3cFA37253523E645A78A0C91B57', +}; + +const USER_OPERATION_EVENT = parseAbiItem( + 'event UserOperationEvent(bytes32 userOpHash,address sender,address paymaster,uint256 nonce,bool success,uint256 actualGasCost,uint256 actualGasUsed)', +); + +const SAFE_EXECUTION_SUCCESS_EVENT = parseAbiItem( + 'event ExecutionSuccess(bytes32 txHash,uint256 payment)', +); + +const SAFE_EXECUTION_FAILURE_EVENT = parseAbiItem( + 'event ExecutionFailure(bytes32 txHash,uint256 payment)', +); + +const SAFE_EXECUTION_SUCCESS_SELECTOR = getEventSelector( + SAFE_EXECUTION_SUCCESS_EVENT, +); + +const SAFE_EXECUTION_FAILURE_SELECTOR = getEventSelector( + SAFE_EXECUTION_FAILURE_EVENT, +); + +type ActionProposalCompletion = + | { + status: 'executed'; + txHash: Hex; + safeTxHash?: Hex; + userOpHash?: Hex; + } + | { + status: 'failed'; + txHash: Hex; + safeTxHash?: Hex; + userOpHash?: Hex; + reason: string; + } + | { + status: 'unverified'; + txHash: Hex; + userOpHash?: Hex; + reason: string; + }; + +function formatExecutionError(error: unknown): string { + if (error instanceof Error) { + const maybeShort = (error as { shortMessage?: string }).shortMessage; + return maybeShort ?? error.message; + } + return String(error); +} + +async function waitForReceiptOrUserOpReceipt({ + chainId, + hash, + timeoutMs = 60_000, + pollMs = 2_000, + lookbackBlocks = 50_000n, +}: { + chainId: SupportedChainId; + hash: Hex; + timeoutMs?: number; + pollMs?: number; + lookbackBlocks?: bigint; +}): Promise<{ + receipt: TransactionReceipt; + txHash: Hex; + userOpHash?: Hex; +} | null> { + const publicClient = getRPCManager().getClient(chainId); + const deadline = Date.now() + timeoutMs; + const entryPoint = ENTRY_POINT_BY_CHAIN[chainId]; + let resolvedTxHash: Hex | null = null; + + while (Date.now() < deadline) { + try { + const targetHash = resolvedTxHash ?? hash; + const receipt = await publicClient.getTransactionReceipt({ + hash: targetHash, + }); + return { + receipt, + txHash: targetHash, + ...(resolvedTxHash ? { userOpHash: hash } : null), + }; + } catch { + // Keep polling until we can retrieve the receipt. + } + + if (entryPoint && !resolvedTxHash) { + try { + const logs = await publicClient.getLogs({ + address: entryPoint, + event: USER_OPERATION_EVENT, + args: { userOpHash: hash }, + fromBlock: -lookbackBlocks, + }); + + const txHash = logs[0]?.transactionHash; + if (txHash) { + resolvedTxHash = txHash; + } + } catch { + // Ignore lookup issues; we'll retry on the next poll tick. + } + } + + await new Promise((resolve) => setTimeout(resolve, pollMs)); + } + + return null; +} + +function findSafeExecutionEvent(receipt: TransactionReceipt): { + status: 'success' | 'failure'; + safeTxHash: Hex; + safeAddress: Address; +} | null { + for (const log of receipt.logs) { + if (log.topics[0] === SAFE_EXECUTION_FAILURE_SELECTOR) { + const decoded = decodeEventLog({ + abi: [SAFE_EXECUTION_FAILURE_EVENT], + data: log.data, + topics: log.topics, + }); + if (decoded.eventName === 'ExecutionFailure') { + return { + status: 'failure', + safeTxHash: decoded.args.txHash as Hex, + safeAddress: log.address as Address, + }; + } + } + } + + for (const log of receipt.logs) { + if (log.topics[0] === SAFE_EXECUTION_SUCCESS_SELECTOR) { + const decoded = decodeEventLog({ + abi: [SAFE_EXECUTION_SUCCESS_EVENT], + data: log.data, + topics: log.topics, + }); + if (decoded.eventName === 'ExecutionSuccess') { + return { + status: 'success', + safeTxHash: decoded.args.txHash as Hex, + safeAddress: log.address as Address, + }; + } + } + } + + return null; +} + +async function verifySafeExecution({ + proposal, + txHash, +}: { + proposal: { payload: unknown }; + txHash: Hex; +}): Promise { + const payload = proposal.payload as VaultPayload; + const chainId = resolvePayloadChainId(payload); + + const receiptResult = await waitForReceiptOrUserOpReceipt({ + chainId, + hash: txHash, + }); + + if (!receiptResult) { + return { + status: 'unverified', + txHash, + reason: 'Timed out waiting for transaction receipt.', + }; + } + + const { receipt, txHash: normalizedTxHash, userOpHash } = receiptResult; + + if (receipt.status !== 'success') { + return { + status: 'failed', + txHash: normalizedTxHash, + userOpHash, + reason: `Transaction ${normalizedTxHash} reverted on-chain.`, + }; + } + + const safeExecution = findSafeExecutionEvent(receipt); + + if (!safeExecution) { + return { + status: 'unverified', + txHash: normalizedTxHash, + userOpHash, + reason: + 'Unable to verify Safe execution (missing ExecutionSuccess/ExecutionFailure logs).', + }; + } + + if (safeExecution.status === 'failure') { + return { + status: 'failed', + txHash: normalizedTxHash, + userOpHash, + safeTxHash: safeExecution.safeTxHash, + reason: `Safe execution failed (ExecutionFailure). safeTxHash: ${safeExecution.safeTxHash}`, + }; + } + + return { + status: 'executed', + txHash: normalizedTxHash, + userOpHash, + safeTxHash: safeExecution.safeTxHash, + }; +} + +type VaultActionStatus = 'executed' | 'failed'; + +async function emitVaultActionCompleted({ + workspaceId, + actor, + proposal, + status, + txHash, + reason, +}: { + workspaceId: string; + actor: string | undefined; + proposal: { id: string; proposalType: string; payload: unknown }; + status: VaultActionStatus; + txHash?: string; + reason?: string; +}) { + const direction = resolveSavingsDirection(proposal.proposalType); + if (!direction) return; + + const payload = proposal.payload as VaultPayload; + const vaultId = await resolveVaultId(payload); + const amount = resolvePayloadAmount(payload); + + await logAuditEvent({ + workspaceId, + actor, + eventType: 'vault.action.completed', + metadata: { + proposal_id: proposal.id, + vault_id: vaultId, + amount, + direction, + status, + ...(txHash ? { tx_hash: txHash } : null), + ...(reason ? { reason } : null), + }, + }); + + await dispatchWebhookEvent({ + workspaceId, + eventType: 'vault.action.completed', + payload: { + proposal_id: proposal.id, + vault_id: vaultId, + amount, + direction, + status, + ...(txHash ? { tx_hash: txHash } : null), + ...(reason ? { reason } : null), + }, + }); + + if (!vaultId) return; + + const safes = await getWorkspaceSafes(workspaceId); + const ownerAddresses = safes.map((safe) => safe.safeAddress as Address); + + if (ownerAddresses.length === 0) return; + + const positions = await getVaultPositions({ ownerAddresses }); + const filtered = positions.filter((position) => position.vaultId === vaultId); + + await logAuditEvent({ + workspaceId, + actor, + eventType: 'vault.position.updated', + metadata: { + vault_id: vaultId, + positions: filtered, + }, + }); + + await dispatchWebhookEvent({ + workspaceId, + eventType: 'vault.position.updated', + payload: { + vault_id: vaultId, + positions: filtered, + count: filtered.length, + }, + }); +} + export const actionProposalsRouter = router({ list: protectedProcedure .input( @@ -146,7 +482,14 @@ export const actionProposalsRouter = router({ }), markExecuted: protectedProcedure - .input(z.object({ id: z.string().uuid(), txHash: z.string().min(1) })) + .input( + z.object({ + id: z.string().uuid(), + txHash: z.string().regex(/^0x[a-fA-F0-9]{64}$/, { + message: 'Invalid transaction hash.', + }), + }), + ) .mutation(async ({ ctx, input }) => { const workspaceId = requireWorkspaceId(ctx.workspaceId); @@ -164,84 +507,135 @@ export const actionProposalsRouter = router({ }); } - await db - .update(actionProposals) - .set({ status: 'executed', txHash: input.txHash }) - .where(eq(actionProposals.id, proposal.id)); - - const direction = resolveSavingsDirection(proposal.proposalType); - if (direction) { - const payload = proposal.payload as VaultPayload; - const vaultId = await resolveVaultId(payload); - const amount = resolvePayloadAmount(payload); + if (proposal.status === 'executed') { + return { + success: true, + status: 'executed' as const, + txHash: proposal.txHash ?? input.txHash, + }; + } - await logAuditEvent({ - workspaceId, - actor: ctx.userId ?? undefined, - eventType: 'vault.action.completed', - metadata: { - proposal_id: proposal.id, - vault_id: vaultId, - amount, - direction, - status: 'executed', - tx_hash: input.txHash, - }, + if (proposal.status !== 'pending' && proposal.status !== 'approved') { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: `Cannot mark proposal executed from status: ${proposal.status}.`, }); + } + + const txHash = input.txHash as Hex; + + let completion: ActionProposalCompletion; + try { + completion = await verifySafeExecution({ proposal, txHash }); + } catch (error) { + await db + .update(actionProposals) + .set({ txHash: input.txHash }) + .where(eq(actionProposals.id, proposal.id)); + + return { + success: false, + status: proposal.status, + txHash: input.txHash, + verified: false, + reason: `Failed to verify execution: ${formatExecutionError(error)}`, + }; + } + + if (completion.status === 'unverified') { + await db + .update(actionProposals) + .set({ txHash: completion.txHash }) + .where(eq(actionProposals.id, proposal.id)); + + return { + success: false, + status: proposal.status, + txHash: completion.txHash, + verified: false, + pending: true, + reason: completion.reason, + ...(completion.userOpHash + ? { userOpHash: completion.userOpHash } + : null), + }; + } - await dispatchWebhookEvent({ + if (completion.status === 'failed') { + await db + .update(actionProposals) + .set({ + status: 'failed', + txHash: completion.txHash, + proposalMessage: completion.reason ?? proposal.proposalMessage, + }) + .where(eq(actionProposals.id, proposal.id)); + + await emitVaultActionCompleted({ workspaceId, - eventType: 'vault.action.completed', - payload: { - proposal_id: proposal.id, - vault_id: vaultId, - amount, - direction, - status: 'executed', - tx_hash: input.txHash, - }, + actor: ctx.userId ?? undefined, + proposal, + status: 'failed', + txHash: completion.txHash, + reason: completion.reason, }); - if (vaultId) { - const safes = await getWorkspaceSafes(workspaceId); - const ownerAddresses = safes.map( - (safe) => safe.safeAddress as Address, - ); - - if (ownerAddresses.length > 0) { - const positions = await getVaultPositions({ ownerAddresses }); - const filtered = positions.filter( - (position) => position.vaultId === vaultId, - ); - - await logAuditEvent({ - workspaceId, - actor: ctx.userId ?? undefined, - eventType: 'vault.position.updated', - metadata: { - vault_id: vaultId, - positions: filtered, - }, - }); - - await dispatchWebhookEvent({ - workspaceId, - eventType: 'vault.position.updated', - payload: { - vault_id: vaultId, - positions: filtered, - count: filtered.length, - }, - }); - } - } + return { + success: false, + status: 'failed' as const, + txHash: completion.txHash, + reason: completion.reason, + ...(completion.safeTxHash + ? { safeTxHash: completion.safeTxHash } + : null), + ...(completion.userOpHash + ? { userOpHash: completion.userOpHash } + : null), + }; } - return { success: true }; + await db + .update(actionProposals) + .set({ + status: 'executed', + txHash: completion.txHash, + }) + .where(eq(actionProposals.id, proposal.id)); + + await emitVaultActionCompleted({ + workspaceId, + actor: ctx.userId ?? undefined, + proposal, + status: 'executed', + txHash: completion.txHash, + }); + + return { + success: true, + status: 'executed' as const, + txHash: completion.txHash, + ...(completion.safeTxHash + ? { safeTxHash: completion.safeTxHash } + : null), + ...(completion.userOpHash + ? { userOpHash: completion.userOpHash } + : null), + }; }), markFailed: protectedProcedure - .input(z.object({ id: z.string().uuid(), reason: z.string().optional() })) + .input( + z.object({ + id: z.string().uuid(), + reason: z.string().optional(), + txHash: z + .string() + .regex(/^0x[a-fA-F0-9]{64}$/, { + message: 'Invalid transaction hash.', + }) + .optional(), + }), + ) .mutation(async ({ ctx, input }) => { const workspaceId = requireWorkspaceId(ctx.workspaceId); @@ -259,82 +653,26 @@ export const actionProposalsRouter = router({ }); } + const txHash = proposal.txHash ?? input.txHash ?? null; + await db .update(actionProposals) .set({ status: 'failed', + txHash, proposalMessage: input.reason ?? proposal.proposalMessage, }) .where(eq(actionProposals.id, proposal.id)); - const direction = resolveSavingsDirection(proposal.proposalType); - if (direction) { - const payload = proposal.payload as VaultPayload; - const vaultId = await resolveVaultId(payload); - const amount = resolvePayloadAmount(payload); - - await logAuditEvent({ - workspaceId, - actor: ctx.userId ?? undefined, - eventType: 'vault.action.completed', - metadata: { - proposal_id: proposal.id, - vault_id: vaultId, - amount, - direction, - status: 'failed', - reason: input.reason, - }, - }); - - await dispatchWebhookEvent({ - workspaceId, - eventType: 'vault.action.completed', - payload: { - proposal_id: proposal.id, - vault_id: vaultId, - amount, - direction, - status: 'failed', - reason: input.reason, - }, - }); - - if (vaultId) { - const safes = await getWorkspaceSafes(workspaceId); - const ownerAddresses = safes.map( - (safe) => safe.safeAddress as Address, - ); - - if (ownerAddresses.length > 0) { - const positions = await getVaultPositions({ ownerAddresses }); - const filtered = positions.filter( - (position) => position.vaultId === vaultId, - ); - - await logAuditEvent({ - workspaceId, - actor: ctx.userId ?? undefined, - eventType: 'vault.position.updated', - metadata: { - vault_id: vaultId, - positions: filtered, - }, - }); - - await dispatchWebhookEvent({ - workspaceId, - eventType: 'vault.position.updated', - payload: { - vault_id: vaultId, - positions: filtered, - count: filtered.length, - }, - }); - } - } - } + await emitVaultActionCompleted({ + workspaceId, + actor: ctx.userId ?? undefined, + proposal, + status: 'failed', + txHash: txHash ?? undefined, + reason: input.reason, + }); - return { success: true }; + return { success: true, status: 'failed' as const, txHash }; }), });