From 7b99fd1b8b584c60279dd6fb40634394b0480772 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 11 Mar 2026 17:03:21 +0000 Subject: [PATCH 1/7] Implement subsidy adjustment for axlUSDC conversion rate in onramp --- .../services/quote/engines/discount/onramp.ts | 117 ++++++++++++++++-- 1 file changed, 105 insertions(+), 12 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/discount/onramp.ts b/apps/api/src/api/services/quote/engines/discount/onramp.ts index dd8cf1ebc..db96dfe37 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp.ts @@ -1,5 +1,14 @@ -import { multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; +import { + EvmToken, + getNetworkFromDestination, + multiplyByPowerOfTen, + Networks, + OnChainToken, + RampDirection +} from "@vortexfi/shared"; import Big from "big.js"; +import logger from "../../../../../config/logger"; +import { getEvmBridgeQuote } from "../../core/squidrouter"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; @@ -29,6 +38,59 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { } } + /** + * Queries squidrouter to determine the actual conversion rate from axlUSDC on Moonbeam + * to the final destination token on the target EVM chain. + * + * The oracle price is based on the Binance USDT-BRL rate, but the Nabla swap on Pendulum + * outputs axlUSDC (not USDT). Since axlUSDC may trade at a discount to USDT via + * squidrouter, using the oracle USDT rate as the axlUSDC subsidy target means the user + * would receive slightly less than the oracle-promised amount after the squidrouter step. + * + * This method fetches the actual axlUSDC → destination token rate so the discount engine + * can back-calculate the precise axlUSDC amount required on Pendulum. + * + * @param ctx - The quote context (must have request.outputCurrency and request.to set) + * @param expectedAxlUSDCDecimal - The oracle-based expected axlUSDC amount used as probe input + * @returns The conversion rate (destination token units per axlUSDC) or null on failure + */ + private async getSquidRouterAxlUSDCConversionRate(ctx: QuoteContext, expectedAxlUSDCDecimal: Big): Promise { + const req = ctx.request; + const toNetwork = getNetworkFromDestination(req.to); + + if (!toNetwork) { + return null; + } + + try { + const bridgeQuote = await getEvmBridgeQuote({ + amountDecimal: expectedAxlUSDCDecimal.toString(), + fromNetwork: Networks.Moonbeam, + inputCurrency: EvmToken.AXLUSDC as unknown as OnChainToken, + outputCurrency: req.outputCurrency as OnChainToken, + rampType: req.rampType, + toNetwork + }); + + if (expectedAxlUSDCDecimal.lte(0) || bridgeQuote.outputAmountDecimal.lte(0)) { + return null; + } + + const conversionRate = bridgeQuote.outputAmountDecimal.div(expectedAxlUSDCDecimal); + logger.info( + `OnRampDiscountEngine: SquidRouter axlUSDC→${req.outputCurrency} rate: ${conversionRate.toFixed(6)} ` + + `(input: ${expectedAxlUSDCDecimal.toFixed(6)} axlUSDC, output: ${bridgeQuote.outputAmountDecimal.toFixed(6)} ${req.outputCurrency})` + ); + return conversionRate; + } catch (error) { + logger.warn( + `OnRampDiscountEngine: Could not fetch SquidRouter axlUSDC→${req.outputCurrency} conversion rate, ` + + `falling back to 1:1 assumption. Error: ${error}` + ); + return null; + } + } + protected async compute(ctx: QuoteContext): Promise { // biome-ignore lint/style/noNonNullAssertion: Context is validated in validate const nablaSwap = ctx.nablaSwap!; @@ -43,35 +105,66 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - // Calculate expected output amount based on oracle price + target discount + // Step 1: Calculate the oracle-based expected output in USDT-equivalent axlUSDC terms. + // For onramps (isOfframp=false): expectedOutput = inputAmount * oraclePrice * (1 + discount) + // The oracle is the Binance USDT-BRL rate expressed as FIAT-USD (e.g., 0.175 USD per 1 BRL). const { - expectedOutput: expectedOutputAmountDecimal, + expectedOutput: oracleExpectedOutputDecimal, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); - const expectedOutputAmountRaw = multiplyByPowerOfTen(expectedOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - // For onramps, we have to deduct the fees from the output amount of the nabla swap + // Step 2: For onramps to EVM chains (not AssetHub), the Nabla output token (axlUSDC on + // Pendulum) is subsequently bridged via squidrouter (Moonbeam → EVM destination). The + // oracle gives a USDT-BRL rate, but axlUSDC may not trade 1:1 with USDT on squidrouter. + // + // Without this adjustment the subsidy would ensure the user has exactly + // `oracleExpectedOutputDecimal` axlUSDC on Pendulum. After squidrouter converts + // axlUSDC → destination token at rate r < 1, the user would receive + // `oracleExpectedOutputDecimal * r` instead of the oracle-promised + // `oracleExpectedOutputDecimal` — a systematic short-pay. + // + // Fix: use the actual squidrouter route to determine the required axlUSDC amount: + // required_axlUSDC = oracle_promised_dest_amount / squidrouter_rate + // After squidrouter: required_axlUSDC * squidrouter_rate = oracle_promised_dest_amount ✓ + let adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal; + if (ctx.request.to !== "assethub") { + const squidRouterRate = await this.getSquidRouterAxlUSDCConversionRate(ctx, oracleExpectedOutputDecimal); + + if (squidRouterRate !== null && squidRouterRate.gt(0)) { + adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal.div(squidRouterRate); + ctx.addNote?.( + `OnRampDiscountEngine: Adjusted expected axlUSDC from ${oracleExpectedOutputDecimal.toFixed(6)} ` + + `to ${adjustedExpectedOutputDecimal.toFixed(6)} (squidRouter rate: ${squidRouterRate.toFixed(6)})` + ); + } + } + + const expectedOutputAmountRaw = multiplyByPowerOfTen(adjustedExpectedOutputDecimal, nablaSwap.outputDecimals).toFixed(0, 0); + + // For onramps, fees are deducted from the nabla output (not before the swap) const deductedFeesAfterSwap = Big(usdFees.network).plus(usdFees.vortex).plus(usdFees.partnerMarkup); const actualOutputAmountDecimal = nablaSwap.outputAmountDecimal.minus(deductedFeesAfterSwap); const actualOutputAmountRaw = multiplyByPowerOfTen(actualOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - // Calculate ideal subsidy (uncapped - the full shortfall needed to reach expected output) - const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(expectedOutputAmountDecimal) + // Calculate ideal subsidy (uncapped - the full shortfall needed to reach adjusted expected output) + const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(adjustedExpectedOutputDecimal) ? new Big(0) - : expectedOutputAmountDecimal.minus(actualOutputAmountDecimal); + : adjustedExpectedOutputDecimal.minus(actualOutputAmountDecimal); const idealSubsidyAmountRaw = multiplyByPowerOfTen(idealSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); // Calculate actual subsidy (capped by maxSubsidy) const actualSubsidyAmountDecimal = - targetDiscount > 0 ? calculateSubsidyAmount(expectedOutputAmountDecimal, actualOutputAmountDecimal, maxSubsidy) : Big(0); + targetDiscount > 0 + ? calculateSubsidyAmount(adjustedExpectedOutputDecimal, actualOutputAmountDecimal, maxSubsidy) + : Big(0); const actualSubsidyAmountRaw = multiplyByPowerOfTen(actualSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); const targetOutputAmountDecimal = actualOutputAmountDecimal.plus(actualSubsidyAmountDecimal); const targetOutputAmountRaw = Big(actualOutputAmountRaw).plus(actualSubsidyAmountRaw).toFixed(0, 0); - const subsidyRate = expectedOutputAmountDecimal.gt(0) - ? actualSubsidyAmountDecimal.div(expectedOutputAmountDecimal) + const subsidyRate = adjustedExpectedOutputDecimal.gt(0) + ? actualSubsidyAmountDecimal.div(adjustedExpectedOutputDecimal) : new Big(0); return { @@ -79,7 +172,7 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { actualOutputAmountRaw, adjustedDifference, adjustedTargetDiscount, - expectedOutputAmountDecimal, + expectedOutputAmountDecimal: adjustedExpectedOutputDecimal, expectedOutputAmountRaw, idealSubsidyAmountInOutputTokenDecimal: idealSubsidyAmountDecimal, idealSubsidyAmountInOutputTokenRaw: idealSubsidyAmountRaw, From a2b9ce1246403c04e976a904465ea5e6848727a1 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 12 Mar 2026 09:18:10 +0000 Subject: [PATCH 2/7] Take anchor fee into account for offramp subsidy --- .../quote/engines/discount/offramp.ts | 57 +++++++++++++++---- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/discount/offramp.ts b/apps/api/src/api/services/quote/engines/discount/offramp.ts index 420673a6d..e62bd9184 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp.ts @@ -1,5 +1,6 @@ import { multiplyByPowerOfTen, RampDirection } from "@vortexfi/shared"; import Big from "big.js"; +import logger from "../../../../../config/logger"; import { QuoteContext } from "../../core/types"; import { BaseDiscountEngine, DiscountComputation } from "."; import { calculateExpectedOutput, calculateSubsidyAmount, resolveDiscountPartner } from "./helpers"; @@ -37,33 +38,69 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - // Calculate expected output amount based on oracle price + target discount + // Step 1: Calculate the oracle-based expected output in BRL. + // For offramps (isOfframp=true): expectedOutput = inputAmount * (1/oraclePrice) * (1 + discount) + // The oracle is the Binance USDT-BRL rate expressed as FIAT-USD (e.g., 0.175 USD per 1 BRL), + // inverted to give BRL per USD (e.g., 5.7 BRL per USDC input). const { - expectedOutput: expectedOutputAmountDecimal, + expectedOutput: oracleExpectedOutputDecimal, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); - const expectedOutputAmountRaw = multiplyByPowerOfTen(expectedOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); + + // Step 2: Account for the anchor fee deducted in the Finalize stage. + // + // The Finalize stage computes the BRL the user receives as: + // final_BRL = pendulumToMoonbeamXcm.outputAmountDecimal - anchorFee + // + // The PendulumTransfer stage sets pendulumToMoonbeamXcm.outputAmountDecimal to: + // nablaOutput + subsidyAmount + // + // Without this adjustment the subsidy targets `oracleExpectedOutputDecimal` BRLA + // on Pendulum, but after the anchor fee deduction the user receives + // `oracleExpectedOutputDecimal - anchorFee` BRL — systematically less than the + // oracle-promised rate. + // + // Fix: add the anchor fee on top of the oracle-promised BRL so that: + // pendulumToMoonbeamXcm = oracle_promised + anchorFee + // final_BRL = (oracle_promised + anchorFee) - anchorFee = oracle_promised ✓ + const anchorFeeInBrl = ctx.fees?.displayFiat?.anchor ? new Big(ctx.fees.displayFiat.anchor) : new Big(0); + const adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal.plus(anchorFeeInBrl); + + if (anchorFeeInBrl.gt(0)) { + logger.info( + `OffRampDiscountEngine: Adjusted expected BRL from ${oracleExpectedOutputDecimal.toFixed(6)} ` + + `to ${adjustedExpectedOutputDecimal.toFixed(6)} (anchor fee: ${anchorFeeInBrl.toFixed(6)} BRL)` + ); + ctx.addNote?.( + `OffRampDiscountEngine: Adjusted expected BRL output from ${oracleExpectedOutputDecimal.toFixed(4)} ` + + `to ${adjustedExpectedOutputDecimal.toFixed(4)} BRL to account for anchor fee of ${anchorFeeInBrl.toFixed(4)} BRL` + ); + } + + const expectedOutputAmountRaw = multiplyByPowerOfTen(adjustedExpectedOutputDecimal, nablaSwap.outputDecimals).toFixed(0, 0); const actualOutputAmountDecimal = nablaSwap.outputAmountDecimal; const actualOutputAmountRaw = multiplyByPowerOfTen(actualOutputAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); - // Calculate ideal subsidy (uncapped - the full shortfall needed to reach expected output) - const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(expectedOutputAmountDecimal) + // Calculate ideal subsidy (uncapped - the full shortfall needed to reach adjusted expected output) + const idealSubsidyAmountDecimal = actualOutputAmountDecimal.gte(adjustedExpectedOutputDecimal) ? new Big(0) - : expectedOutputAmountDecimal.minus(actualOutputAmountDecimal); + : adjustedExpectedOutputDecimal.minus(actualOutputAmountDecimal); const idealSubsidyAmountRaw = multiplyByPowerOfTen(idealSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); // Calculate actual subsidy (capped by maxSubsidy) const actualSubsidyAmountDecimal = - targetDiscount > 0 ? calculateSubsidyAmount(expectedOutputAmountDecimal, actualOutputAmountDecimal, maxSubsidy) : Big(0); + targetDiscount > 0 + ? calculateSubsidyAmount(adjustedExpectedOutputDecimal, actualOutputAmountDecimal, maxSubsidy) + : Big(0); const actualSubsidyAmountRaw = multiplyByPowerOfTen(actualSubsidyAmountDecimal, nablaSwap.outputDecimals).toFixed(0, 0); const targetOutputAmountDecimal = actualOutputAmountDecimal.plus(actualSubsidyAmountDecimal); const targetOutputAmountRaw = Big(actualOutputAmountRaw).plus(actualSubsidyAmountRaw).toFixed(0, 0); - const subsidyRate = expectedOutputAmountDecimal.gt(0) - ? actualSubsidyAmountDecimal.div(expectedOutputAmountDecimal) + const subsidyRate = adjustedExpectedOutputDecimal.gt(0) + ? actualSubsidyAmountDecimal.div(adjustedExpectedOutputDecimal) : new Big(0); return { @@ -71,7 +108,7 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { actualOutputAmountRaw, adjustedDifference, adjustedTargetDiscount, - expectedOutputAmountDecimal, + expectedOutputAmountDecimal: adjustedExpectedOutputDecimal, expectedOutputAmountRaw, idealSubsidyAmountInOutputTokenDecimal: idealSubsidyAmountDecimal, idealSubsidyAmountInOutputTokenRaw: idealSubsidyAmountRaw, From 9901e8e4c015745155c1feb749fdac9324140314 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Thu, 12 Mar 2026 09:18:18 +0000 Subject: [PATCH 3/7] Adjust .gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 714c0a222..3f633518c 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,7 @@ storybook-static CLAUDE.local.md -.claude \ No newline at end of file +.claude +/contracts/relayer/artifacts/* +/contracts/relayer/cache/* +/.roo/* From a31b5d05e352201ff84b1119bfbfc85ae6c7ca70 Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Fri, 13 Mar 2026 18:53:20 +0000 Subject: [PATCH 4/7] Adjust comments --- .../quote/engines/discount/offramp.ts | 23 +++---------------- .../services/quote/engines/discount/onramp.ts | 17 +++----------- 2 files changed, 6 insertions(+), 34 deletions(-) diff --git a/apps/api/src/api/services/quote/engines/discount/offramp.ts b/apps/api/src/api/services/quote/engines/discount/offramp.ts index e62bd9184..53d151506 100644 --- a/apps/api/src/api/services/quote/engines/discount/offramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/offramp.ts @@ -38,32 +38,15 @@ export class OffRampDiscountEngine extends BaseDiscountEngine { const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - // Step 1: Calculate the oracle-based expected output in BRL. - // For offramps (isOfframp=true): expectedOutput = inputAmount * (1/oraclePrice) * (1 + discount) - // The oracle is the Binance USDT-BRL rate expressed as FIAT-USD (e.g., 0.175 USD per 1 BRL), - // inverted to give BRL per USD (e.g., 5.7 BRL per USDC input). + // Calculate the oracle-based expected output in BRL. const { expectedOutput: oracleExpectedOutputDecimal, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); - // Step 2: Account for the anchor fee deducted in the Finalize stage. - // - // The Finalize stage computes the BRL the user receives as: - // final_BRL = pendulumToMoonbeamXcm.outputAmountDecimal - anchorFee - // - // The PendulumTransfer stage sets pendulumToMoonbeamXcm.outputAmountDecimal to: - // nablaOutput + subsidyAmount - // - // Without this adjustment the subsidy targets `oracleExpectedOutputDecimal` BRLA - // on Pendulum, but after the anchor fee deduction the user receives - // `oracleExpectedOutputDecimal - anchorFee` BRL — systematically less than the - // oracle-promised rate. - // - // Fix: add the anchor fee on top of the oracle-promised BRL so that: - // pendulumToMoonbeamXcm = oracle_promised + anchorFee - // final_BRL = (oracle_promised + anchorFee) - anchorFee = oracle_promised ✓ + // Account for the anchor fee deducted in the Finalize stage, which reduces the user's received amount. + // We need to add it back to the expected output to calculate the subsidy correctly. const anchorFeeInBrl = ctx.fees?.displayFiat?.anchor ? new Big(ctx.fees.displayFiat.anchor) : new Big(0); const adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal.plus(anchorFeeInBrl); diff --git a/apps/api/src/api/services/quote/engines/discount/onramp.ts b/apps/api/src/api/services/quote/engines/discount/onramp.ts index db96dfe37..cf851b175 100644 --- a/apps/api/src/api/services/quote/engines/discount/onramp.ts +++ b/apps/api/src/api/services/quote/engines/discount/onramp.ts @@ -105,28 +105,17 @@ export class OnRampDiscountEngine extends BaseDiscountEngine { const targetDiscount = partner?.targetDiscount ?? 0; const maxSubsidy = partner?.maxSubsidy ?? 0; - // Step 1: Calculate the oracle-based expected output in USDT-equivalent axlUSDC terms. - // For onramps (isOfframp=false): expectedOutput = inputAmount * oraclePrice * (1 + discount) - // The oracle is the Binance USDT-BRL rate expressed as FIAT-USD (e.g., 0.175 USD per 1 BRL). + // Calculate the oracle-based expected output in USDT-equivalent axlUSDC terms. const { expectedOutput: oracleExpectedOutputDecimal, adjustedDifference, adjustedTargetDiscount } = calculateExpectedOutput(inputAmount, oraclePrice, targetDiscount, this.config.isOfframp, partner); - // Step 2: For onramps to EVM chains (not AssetHub), the Nabla output token (axlUSDC on + // For onramps to EVM chains (not AssetHub), the Nabla output token (axlUSDC on // Pendulum) is subsequently bridged via squidrouter (Moonbeam → EVM destination). The // oracle gives a USDT-BRL rate, but axlUSDC may not trade 1:1 with USDT on squidrouter. - // - // Without this adjustment the subsidy would ensure the user has exactly - // `oracleExpectedOutputDecimal` axlUSDC on Pendulum. After squidrouter converts - // axlUSDC → destination token at rate r < 1, the user would receive - // `oracleExpectedOutputDecimal * r` instead of the oracle-promised - // `oracleExpectedOutputDecimal` — a systematic short-pay. - // - // Fix: use the actual squidrouter route to determine the required axlUSDC amount: - // required_axlUSDC = oracle_promised_dest_amount / squidrouter_rate - // After squidrouter: required_axlUSDC * squidrouter_rate = oracle_promised_dest_amount ✓ + // So we use the actual squidrouter route to determine the required axlUSDC amount let adjustedExpectedOutputDecimal = oracleExpectedOutputDecimal; if (ctx.request.to !== "assethub") { const squidRouterRate = await this.getSquidRouterAxlUSDCConversionRate(ctx, oracleExpectedOutputDecimal); From 518dba6b8b775b0145dcda796155878640cdb562 Mon Sep 17 00:00:00 2001 From: Gianfranco Date: Tue, 24 Mar 2026 16:30:09 -0300 Subject: [PATCH 5/7] do not alter db state upon transition --- apps/api/src/api/services/phases/base-phase-handler.ts | 9 ++++----- apps/api/src/api/services/phases/phase-processor.ts | 5 ++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/api/src/api/services/phases/base-phase-handler.ts b/apps/api/src/api/services/phases/base-phase-handler.ts index 9330d3967..5567d1218 100644 --- a/apps/api/src/api/services/phases/base-phase-handler.ts +++ b/apps/api/src/api/services/phases/base-phase-handler.ts @@ -93,9 +93,9 @@ export abstract class BasePhaseHandler implements PhaseHandler { * @param state The current ramp state * @param nextPhase The next phase * @param metadata Additional metadata for the transition - * @returns The updated ramp state + * @returns The updated ramp state. Returns a new in-memory instance. */ - protected async transitionToNextPhase(state: RampState, nextPhase: RampPhase, metadata?: unknown): Promise { + protected transitionToNextPhase(state: RampState, nextPhase: RampPhase, metadata?: unknown): RampState { const phaseHistory = [ ...state.phaseHistory, { @@ -105,12 +105,11 @@ export abstract class BasePhaseHandler implements PhaseHandler { } ]; - await state.update({ + return RampState.build({ + ...state.get(), currentPhase: nextPhase, phaseHistory }); - - return state.reload(); } /** diff --git a/apps/api/src/api/services/phases/phase-processor.ts b/apps/api/src/api/services/phases/phase-processor.ts index 43f6b4169..0260d51a8 100644 --- a/apps/api/src/api/services/phases/phase-processor.ts +++ b/apps/api/src/api/services/phases/phase-processor.ts @@ -171,10 +171,13 @@ export class PhaseProcessor { }, maxExecuteTime); }); - const updatedState = await Promise.race([handler.execute(state), timeoutPromise]).finally(() => { + const pendingState = await Promise.race([handler.execute(state), timeoutPromise]).finally(() => { clearTimeout(timeoutId); }); + // Single source of authority for phase transitions. + const updatedState = await pendingState.save(); + // If the phase has changed, process the next phase // except for complete or fail phases which are terminal. if ( From 12f10f40c82fb325fdf7c79b4134c34bd7ffb30e Mon Sep 17 00:00:00 2001 From: gianfra-t <96739519+gianfra-t@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:36:44 -0300 Subject: [PATCH 6/7] Update apps/api/src/api/services/phases/phase-processor.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/api/src/api/services/phases/phase-processor.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/api/src/api/services/phases/phase-processor.ts b/apps/api/src/api/services/phases/phase-processor.ts index 0260d51a8..319bfdc6c 100644 --- a/apps/api/src/api/services/phases/phase-processor.ts +++ b/apps/api/src/api/services/phases/phase-processor.ts @@ -176,7 +176,12 @@ export class PhaseProcessor { }); // Single source of authority for phase transitions. - const updatedState = await pendingState.save(); + // Persist only the phase-related fields on the original persisted instance + // to avoid inserting new records or clobbering unrelated columns. + const updatedState = await state.update( + { currentPhase: pendingState.currentPhase }, + { fields: ["currentPhase"] } + ); // If the phase has changed, process the next phase // except for complete or fail phases which are terminal. From ca7844b75215efcd943137a9a6ad0e8a77b5d76b Mon Sep 17 00:00:00 2001 From: Marcel Ebert Date: Wed, 25 Mar 2026 19:24:06 +0100 Subject: [PATCH 7/7] Update phase-processor to persist phaseHistory along with currentPhase --- apps/api/src/api/services/phases/phase-processor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/api/services/phases/phase-processor.ts b/apps/api/src/api/services/phases/phase-processor.ts index 319bfdc6c..c033f1c52 100644 --- a/apps/api/src/api/services/phases/phase-processor.ts +++ b/apps/api/src/api/services/phases/phase-processor.ts @@ -179,8 +179,8 @@ export class PhaseProcessor { // Persist only the phase-related fields on the original persisted instance // to avoid inserting new records or clobbering unrelated columns. const updatedState = await state.update( - { currentPhase: pendingState.currentPhase }, - { fields: ["currentPhase"] } + { currentPhase: pendingState.currentPhase, phaseHistory: pendingState.phaseHistory }, + { fields: ["currentPhase", "phaseHistory"] } ); // If the phase has changed, process the next phase