From 668b5c5c3764cd8399b0eb696aabecb1f4d94c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= Date: Sat, 29 Aug 2026 20:49:57 +0200 Subject: [PATCH 01/18] feat: add generic evidence-bound preparation --- .../prepare-swap-evidence-bound.test.ts | 289 ++++++++++++++++++ ...evidence-bound-preparation.service.test.ts | 268 ++++++++++++++++ .../usecases/prepare-swap.usecase.ts | 56 +--- .../evidence-bound-preparation.service.ts | 73 +++++ 4 files changed, 646 insertions(+), 40 deletions(-) create mode 100644 backend/src/__tests__/modules/avax-swap/prepare-swap-evidence-bound.test.ts create mode 100644 backend/src/__tests__/shared/services/evidence-bound-preparation.service.test.ts create mode 100644 backend/src/shared/services/evidence-bound-preparation.service.ts diff --git a/backend/src/__tests__/modules/avax-swap/prepare-swap-evidence-bound.test.ts b/backend/src/__tests__/modules/avax-swap/prepare-swap-evidence-bound.test.ts new file mode 100644 index 0000000..50bc603 --- /dev/null +++ b/backend/src/__tests__/modules/avax-swap/prepare-swap-evidence-bound.test.ts @@ -0,0 +1,289 @@ +import { + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { ethers } from "ethers"; + +const EXECUTOR = + "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12"; + +const WAVAX = + "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7"; + +const USDC = + "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E"; + +const USER = + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +const CORRELATION_ID = + "11111111-2222-4333-8444-555555555555"; + +const { + mockGetQuoteWithHop, + mockCheckAllowance, + mockEstimateGas, + mockCreateEvidenceCorrelation, + mockPersistEvidenceIntent, + mockPersistPreparedEvidence, +} = vi.hoisted(() => ({ + mockGetQuoteWithHop: vi.fn(), + mockCheckAllowance: vi.fn(), + mockEstimateGas: vi.fn(), + mockCreateEvidenceCorrelation: vi.fn(), + mockPersistEvidenceIntent: vi.fn(), + mockPersistPreparedEvidence: vi.fn(), +})); + +vi.mock("../../../config/chains", () => ({ + getChainConfig: vi.fn(() => ({ + chainId: 43114, + name: "Avalanche", + contracts: { + panoramaExecutor: EXECUTOR, + }, + })), +})); + +vi.mock("../../../providers/chain.provider", () => ({ + getProvider: vi.fn(() => ({ + estimateGas: mockEstimateGas, + })), +})); + +vi.mock("../../../shared/services/avax.service", () => ({ + WAVAX: "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7", + avaxService: { + getQuoteWithHop: mockGetQuoteWithHop, + checkAllowance: mockCheckAllowance, + }, +})); + +vi.mock( + "../../../shared/services/transaction-evidence.service", + () => ({ + createEvidenceCorrelation: mockCreateEvidenceCorrelation, + persistEvidenceIntent: mockPersistEvidenceIntent, + persistPreparedEvidence: mockPersistPreparedEvidence, + }) +); + +import { + executePrepareAvaxSwap, +} from "../../../modules/avax-swap/usecases/prepare-swap.usecase"; + +import { + ERC20_APPROVE_ABI, + PANORAMA_EXECUTOR_ABI_EXECUTE, + TRADERJOE_SELECTORS, +} from "../../../shared/bundle-builder"; + +describe("executePrepareAvaxSwap evidence-bound migration", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockEstimateGas.mockResolvedValue(100_000n); + + mockGetQuoteWithHop.mockResolvedValue({ + amountOut: 2_000_000n, + path: [USDC, WAVAX], + }); + + mockCheckAllowance.mockResolvedValue(0n); + + mockCreateEvidenceCorrelation.mockReturnValue({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + enabled: true, + }); + + mockPersistEvidenceIntent.mockResolvedValue(undefined); + + mockPersistPreparedEvidence.mockImplementation( + async (_intent, bundle) => ({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + evidenceEnabled: true, + preparedPayloadHash: + ethers.keccak256( + ethers.toUtf8Bytes(JSON.stringify(bundle)) + ), + }) + ); + }); + + it("preserves ERC-20 TraderJoe transaction semantics through the evidence boundary", async () => { + const result = await executePrepareAvaxSwap({ + userAddress: USER, + tokenIn: USDC, + tokenOut: WAVAX, + amountIn: "1000000", + slippageBps: 50, + deadlineMinutes: 20, + }); + + expect(result.correlationId).toBe(CORRELATION_ID); + expect(result.evidenceVersion).toBe("1.0"); + expect(result.evidenceEnabled).toBe(true); + + expect(result.bundle.totalSteps).toBe(2); + expect(result.bundle.steps).toHaveLength(2); + + const approve = result.bundle.steps[0]; + + expect(approve.to).toBe(USDC); + expect(approve.value).toBe("0"); + expect(approve.chainId).toBe(43114); + expect(approve.gas).toBe("0x1fbd0"); + + const approveInterface = + new ethers.Interface(ERC20_APPROVE_ABI); + + const approveDecoded = + approveInterface.decodeFunctionData( + "approve", + approve.data + ); + + expect(approveDecoded[0]).toBe(EXECUTOR); + expect(approveDecoded[1]).toBe(1_000_000n); + + const execute = result.bundle.steps[1]; + + expect(execute.to).toBe(EXECUTOR); + expect(execute.value).toBe("0"); + expect(execute.chainId).toBe(43114); + expect(execute.gas).toBeUndefined(); + + const executorInterface = + new ethers.Interface( + PANORAMA_EXECUTOR_ABI_EXECUTE + ); + + const decoded = + executorInterface.decodeFunctionData( + "execute", + execute.data + ); + + expect(decoded[0]).toBe( + ethers.keccak256( + ethers.toUtf8Bytes("traderjoe") + ) + ); + + expect(decoded[1]).toBe( + TRADERJOE_SELECTORS.SWAP_WITH_PATH + ); + + expect(decoded[2]).toHaveLength(1); + expect(decoded[2][0].token).toBe(USDC); + expect(decoded[2][0].amount).toBe(1_000_000n); + + expect(Number(decoded[3])).toBeGreaterThan( + Math.floor(Date.now() / 1000) + ); + + const adapterDecoded = + ethers.AbiCoder.defaultAbiCoder().decode( + [ + "uint256", + "uint256", + "address[]", + "address", + ], + decoded[4] + ); + + expect(adapterDecoded[0]).toBe(1_000_000n); + expect(adapterDecoded[1]).toBe(1_990_000n); + + expect( + adapterDecoded[2].map( + (address: string) => address.toLowerCase() + ) + ).toEqual([ + USDC.toLowerCase(), + WAVAX.toLowerCase(), + ]); + + expect(adapterDecoded[3]).toBe(USER); + + expect(result.metadata).toMatchObject({ + tokenIn: USDC, + tokenOut: WAVAX, + amountIn: "1000000", + amountOut: "2000000", + amountOutMin: "1990000", + path: [USDC, WAVAX], + swapType: "token-to-avax", + slippageBps: 50, + }); + + expect(mockPersistEvidenceIntent) + .toHaveBeenCalledTimes(1); + + expect(mockPersistPreparedEvidence) + .toHaveBeenCalledTimes(1); + + const persistedBundle = + mockPersistPreparedEvidence.mock.calls[0][1]; + + expect(persistedBundle).toBe(result.bundle); + }); + + it("preserves the existing AVAX/WAVAX wrap transaction semantics", async () => { + const amount = "1000000000000000000"; + + const result = await executePrepareAvaxSwap({ + userAddress: USER, + tokenIn: WAVAX, + tokenOut: WAVAX, + amountIn: amount, + slippageBps: 50, + }); + + expect(result.bundle.totalSteps).toBe(1); + expect(result.bundle.steps).toHaveLength(1); + + const step = result.bundle.steps[0]; + + expect(step.to).toBe(WAVAX); + expect(step.value).toBe(amount); + expect(step.chainId).toBe(43114); + expect(step.description).toBe( + "Wrap AVAX → WAVAX" + ); + + const wrapInterface = + new ethers.Interface([ + "function deposit() external payable", + ]); + + expect(step.data).toBe( + wrapInterface.encodeFunctionData( + "deposit", + [] + ) + ); + + expect(mockGetQuoteWithHop) + .not.toHaveBeenCalled(); + + expect(mockCheckAllowance) + .not.toHaveBeenCalled(); + + expect(mockPersistEvidenceIntent) + .toHaveBeenCalledTimes(1); + + expect(mockPersistPreparedEvidence) + .toHaveBeenCalledTimes(1); + + expect( + mockPersistPreparedEvidence.mock.calls[0][1] + ).toBe(result.bundle); + }); +}); diff --git a/backend/src/__tests__/shared/services/evidence-bound-preparation.service.test.ts b/backend/src/__tests__/shared/services/evidence-bound-preparation.service.test.ts new file mode 100644 index 0000000..4d51e5e --- /dev/null +++ b/backend/src/__tests__/shared/services/evidence-bound-preparation.service.test.ts @@ -0,0 +1,268 @@ +import { + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { TransactionBundle } from "../../../types/transaction"; + +const { + mockCreateEvidenceCorrelation, + mockPersistEvidenceIntent, + mockPersistPreparedEvidence, +} = vi.hoisted(() => ({ + mockCreateEvidenceCorrelation: vi.fn(), + mockPersistEvidenceIntent: vi.fn(), + mockPersistPreparedEvidence: vi.fn(), +})); + +vi.mock( + "../../../shared/services/transaction-evidence.service", + () => ({ + createEvidenceCorrelation: mockCreateEvidenceCorrelation, + persistEvidenceIntent: mockPersistEvidenceIntent, + persistPreparedEvidence: mockPersistPreparedEvidence, + }) +); + +import { + prepareEvidenceBoundBundle, +} from "../../../shared/services/evidence-bound-preparation.service"; + +const CORRELATION_ID = + "11111111-2222-4333-8444-555555555555"; + +const USER = + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +const TOKEN_IN = + "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E"; + +const TOKEN_OUT = + "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7"; + +const EXECUTOR = + "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12"; + +const bundle: TransactionBundle = { + steps: [ + { + to: EXECUTOR, + data: "0x12345678", + value: "0", + chainId: 43114, + description: "Swap", + }, + ], + totalSteps: 1, + summary: "Swap USDC to AVAX", +}; + +const metadata = { + service: "SWAP", + selectedProvider: "traderjoe", +}; + +const intent = { + action: "swap", + chainId: 43114, + network: "avalanche-c-chain", + walletAddress: USER, + assetIn: TOKEN_IN, + assetOut: TOKEN_OUT, + amountRaw: "1000000", + slippageBps: 50, +}; + +describe("prepareEvidenceBoundBundle", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockCreateEvidenceCorrelation.mockReturnValue({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + enabled: true, + }); + + mockPersistEvidenceIntent.mockResolvedValue(undefined); + + mockPersistPreparedEvidence.mockResolvedValue({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + evidenceEnabled: true, + preparedPayloadHash: `0x${"ab".repeat(32)}`, + }); + }); + + it("persists intent before transaction preparation starts", async () => { + const events: string[] = []; + + mockPersistEvidenceIntent.mockImplementation(async () => { + events.push("intent"); + }); + + const prepare = vi.fn(async () => { + events.push("prepare"); + return { bundle, metadata }; + }); + + mockPersistPreparedEvidence.mockImplementation(async () => { + events.push("prepared-evidence"); + + return { + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + evidenceEnabled: true, + preparedPayloadHash: `0x${"ab".repeat(32)}`, + }; + }); + + await prepareEvidenceBoundBundle({ + intent, + prepare, + }); + + expect(events).toEqual([ + "intent", + "prepare", + "prepared-evidence", + ]); + }); + + it("creates the correlation and timestamps the evidence intent", async () => { + await prepareEvidenceBoundBundle({ + intent, + prepare: async () => ({ bundle, metadata }), + }); + + expect(mockCreateEvidenceCorrelation).toHaveBeenCalledTimes(1); + + expect(mockPersistEvidenceIntent).toHaveBeenCalledTimes(1); + + const persistedIntent = + mockPersistEvidenceIntent.mock.calls[0][0]; + + expect(persistedIntent).toMatchObject({ + ...intent, + correlationId: CORRELATION_ID, + }); + + expect( + Number.isNaN(Date.parse(persistedIntent.createdAt)) + ).toBe(false); + }); + + it("persists the exact prepared bundle and metadata", async () => { + await prepareEvidenceBoundBundle({ + intent, + prepare: async () => ({ bundle, metadata }), + }); + + expect(mockPersistPreparedEvidence) + .toHaveBeenCalledTimes(1); + + expect( + mockPersistPreparedEvidence.mock.calls[0][1] + ).toBe(bundle); + + expect( + mockPersistPreparedEvidence.mock.calls[0][2] + ).toBe(metadata); + }); + + it("returns the exact bundle and metadata without mutation", async () => { + const bundleSnapshot = structuredClone(bundle); + const metadataSnapshot = structuredClone(metadata); + + const result = await prepareEvidenceBoundBundle({ + intent, + prepare: async () => ({ bundle, metadata }), + }); + + expect(result.bundle).toBe(bundle); + expect(result.metadata).toBe(metadata); + + expect(bundle).toEqual(bundleSnapshot); + expect(metadata).toEqual(metadataSnapshot); + }); + + it("returns the prepared evidence identity with the bundle", async () => { + const result = await prepareEvidenceBoundBundle({ + intent, + prepare: async () => ({ bundle, metadata }), + }); + + expect(result).toMatchObject({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + evidenceEnabled: true, + preparedPayloadHash: `0x${"ab".repeat(32)}`, + }); + }); + + it("does not prepare or return a bundle when intent persistence fails", async () => { + const prepare = vi.fn(async () => ({ + bundle, + metadata, + })); + + mockPersistEvidenceIntent.mockRejectedValue( + new Error("intent persistence failed") + ); + + await expect( + prepareEvidenceBoundBundle({ + intent, + prepare, + }) + ).rejects.toThrow("intent persistence failed"); + + expect(prepare).not.toHaveBeenCalled(); + + expect(mockPersistPreparedEvidence) + .not.toHaveBeenCalled(); + }); + + it("does not persist prepared evidence when preparation fails", async () => { + const prepare = vi.fn(async () => { + throw new Error("preparation failed"); + }); + + await expect( + prepareEvidenceBoundBundle({ + intent, + prepare, + }) + ).rejects.toThrow("preparation failed"); + + expect(mockPersistEvidenceIntent) + .toHaveBeenCalledTimes(1); + + expect(mockPersistPreparedEvidence) + .not.toHaveBeenCalled(); + }); + + it("does not return a bundle when prepared evidence persistence fails", async () => { + mockPersistPreparedEvidence.mockRejectedValue( + new Error("prepared persistence failed") + ); + + await expect( + prepareEvidenceBoundBundle({ + intent, + prepare: async () => ({ + bundle, + metadata, + }), + }) + ).rejects.toThrow("prepared persistence failed"); + + expect(mockPersistEvidenceIntent) + .toHaveBeenCalledTimes(1); + + expect(mockPersistPreparedEvidence) + .toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts b/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts index fb2f4a2..b0d866e 100644 --- a/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts +++ b/backend/src/modules/avax-swap/usecases/prepare-swap.usecase.ts @@ -7,11 +7,8 @@ import { TransactionBundle } from "../../../types/transaction"; import { AppError } from "../../../shared/errorCodes"; import { logger } from "../../../shared/logger"; import { - createEvidenceCorrelation, - persistEvidenceIntent, - persistPreparedEvidence, - EvidenceIntentInput, -} from "../../../shared/services/transaction-evidence.service"; + prepareEvidenceBoundBundle, +} from "../../../shared/services/evidence-bound-preparation.service"; export interface PrepareAvaxSwapRequest { userAddress: string; @@ -67,21 +64,18 @@ export async function executePrepareAvaxSwap( const tokenInLower = req.tokenIn.toLowerCase(); const tokenOutLower = req.tokenOut.toLowerCase(); - const evidence = createEvidenceCorrelation(); - const evidenceIntent: EvidenceIntentInput = { - correlationId: evidence.correlationId, - createdAt: new Date().toISOString(), - action: "swap", - chainId: chain.chainId, - network: "avalanche-c-chain", - walletAddress: req.userAddress, - assetIn: req.tokenIn, - assetOut: req.tokenOut, - amountRaw: amountIn.toString(), - slippageBps, - }; - - await persistEvidenceIntent(evidenceIntent); + return prepareEvidenceBoundBundle({ + intent: { + action: "swap", + chainId: chain.chainId, + network: "avalanche-c-chain", + walletAddress: req.userAddress, + assetIn: req.tokenIn, + assetOut: req.tokenOut, + amountRaw: amountIn.toString(), + slippageBps, + }, + prepare: async () => { // ── Wrap: AVAX → WAVAX ────────────────────────────────────────────── if (tokenInLower === WAVAX_LOWER && tokenOutLower === WAVAX_LOWER) { @@ -108,17 +102,7 @@ export async function executePrepareAvaxSwap( priceImpact: "0", }; - const preparedEvidence = await persistPreparedEvidence( - evidenceIntent, - bundle, - metadata - ); - return { - correlationId: preparedEvidence.correlationId, - evidenceVersion: preparedEvidence.evidenceVersion, - evidenceEnabled: preparedEvidence.evidenceEnabled, - preparedPayloadHash: preparedEvidence.preparedPayloadHash, bundle, metadata, }; @@ -200,18 +184,10 @@ export async function executePrepareAvaxSwap( priceImpact, }; - const preparedEvidence = await persistPreparedEvidence( - evidenceIntent, - bundle, - metadata - ); - return { - correlationId: preparedEvidence.correlationId, - evidenceVersion: preparedEvidence.evidenceVersion, - evidenceEnabled: preparedEvidence.evidenceEnabled, - preparedPayloadHash: preparedEvidence.preparedPayloadHash, bundle, metadata, }; + }, + }); } diff --git a/backend/src/shared/services/evidence-bound-preparation.service.ts b/backend/src/shared/services/evidence-bound-preparation.service.ts new file mode 100644 index 0000000..535e6c8 --- /dev/null +++ b/backend/src/shared/services/evidence-bound-preparation.service.ts @@ -0,0 +1,73 @@ +import { TransactionBundle } from "../../types/transaction"; +import { + createEvidenceCorrelation, + EvidenceIntentInput, + persistEvidenceIntent, + persistPreparedEvidence, + PreparedEvidenceResult, +} from "./transaction-evidence.service"; + +export type EvidenceIntentDescriptor = + Omit; + +export interface PreparedOperation< + TMetadata extends Record +> { + bundle: TransactionBundle; + metadata: TMetadata; +} + +export type EvidenceBoundPreparationResult< + TMetadata extends Record +> = + PreparedEvidenceResult & + PreparedOperation; + +/** + * Generic evidence-bound preparation boundary. + * + * Ordering invariant: + * + * T1 create + persist intent + * T2 prepare deterministic transaction bundle + * T3 persist prepared transaction commitment + * T4 return executable bundle to caller + * + * Therefore no caller using this boundary can receive an executable bundle + * before its evidence preparation lifecycle has completed successfully. + * + * This function does not alter transaction semantics. The bundle returned is + * the exact bundle instance produced by the preparation callback. + */ +export async function prepareEvidenceBoundBundle< + TMetadata extends Record +>( + input: { + intent: EvidenceIntentDescriptor; + prepare: () => Promise>; + } +): Promise> { + const evidence = createEvidenceCorrelation(); + + const evidenceIntent: EvidenceIntentInput = { + ...input.intent, + correlationId: evidence.correlationId, + createdAt: new Date().toISOString(), + }; + + await persistEvidenceIntent(evidenceIntent); + + const prepared = await input.prepare(); + + const preparedEvidence = await persistPreparedEvidence( + evidenceIntent, + prepared.bundle, + prepared.metadata + ); + + return { + ...preparedEvidence, + bundle: prepared.bundle, + metadata: prepared.metadata, + }; +} From 62d0d136fb13b5c1df349c4a11f46b57bdb539aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= Date: Sun, 30 Aug 2026 10:45:58 +0200 Subject: [PATCH 02/18] feat: export evidence by chain participation --- ...ction-evidence-chain-participation.test.ts | 264 ++++++++++++++++++ .../services/transaction-evidence.service.ts | 85 +++--- 2 files changed, 316 insertions(+), 33 deletions(-) create mode 100644 backend/src/__tests__/shared/services/transaction-evidence-chain-participation.test.ts diff --git a/backend/src/__tests__/shared/services/transaction-evidence-chain-participation.test.ts b/backend/src/__tests__/shared/services/transaction-evidence-chain-participation.test.ts new file mode 100644 index 0000000..2bb226f --- /dev/null +++ b/backend/src/__tests__/shared/services/transaction-evidence-chain-participation.test.ts @@ -0,0 +1,264 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + exportTransactionEvidenceAdmin, + exportTransactionEvidenceByWallet, +} from "../../../shared/services/transaction-evidence.service"; + +const WALLET_A = + "0x1111111111111111111111111111111111111111"; + +const WALLET_B = + "0x2222222222222222222222222222222222222222"; + +const response = (data: unknown) => + Promise.resolve({ + ok: true, + status: 200, + text: async () => JSON.stringify(data), + } as Response); + +const parent = ( + correlationId: string, + walletAddress: string, + chainId: number +) => ({ + correlationId, + evidenceVersion: "1.0", + action: "test", + chainId, + network: chainId === 43114 ? "avalanche" : "base", + walletAddress, + intent: {}, + preparedPayloadHash: "0xprepared", + preparedAt: "2026-08-30T08:00:00.000Z", + preparedMetadata: { + totalSteps: 1, + }, + status: "verified", + verificationStatus: "verified", + createdAt: "2026-08-30T08:00:00.000Z", + updatedAt: "2026-08-30T08:01:00.000Z", + verifiedAt: "2026-08-30T08:01:00.000Z", +}); + +const step = ( + correlationId: string, + stepIndex: number, + chainId: number +) => ({ + id: `${correlationId}:${stepIndex}`, + correlationId, + stepIndex, + action: "swap", + chainId, + toAddress: + "0x3333333333333333333333333333333333333333", + value: "0", + dataHash: + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + preparedStepHash: + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + preparedAt: "2026-08-30T08:00:00.000Z", + txHash: + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + submittedAt: "2026-08-30T08:00:30.000Z", + verified: true, + chainMatchesExpected: true, +}); + +describe("transaction evidence chain participation export", () => { + beforeEach(() => { + process.env.PHASE2_EVIDENCE_ENABLED = "true"; + process.env.DB_GATEWAY_URL = "http://gateway.test"; + process.env.DB_GATEWAY_SERVICE_TOKEN = "test-token"; + process.env.DB_GATEWAY_TENANT_ID = "panorama-test"; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("includes a correlation when Avalanche participates in a step even if the parent chain is Base", async () => { + const mixedParent = parent("corr-mixed", WALLET_A, 8453); + const mixedStep = step("corr-mixed", 0, 43114); + + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + + if (url.includes("/v1/transaction-evidence-steps?")) { + return response({ + data: [mixedStep], + }); + } + + if (url.endsWith("/v1/transaction-evidence/corr-mixed")) { + return response({ + data: mixedParent, + }); + } + + if ( + url.endsWith( + "/v1/transaction-evidence-steps/corr-mixed%3A0" + ) + ) { + return response({ + data: mixedStep, + }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const exported = + await exportTransactionEvidenceAdmin(43114); + + expect(exported.records).toHaveLength(1); + expect(exported.records[0].correlationId).toBe( + "corr-mixed" + ); + expect(exported.records[0].intent.chainId).toBe(8453); + expect(exported.records[0].steps[0].chainId).toBe(43114); + }); + + it("deduplicates correlations with multiple Avalanche steps", async () => { + const record = { + ...parent("corr-multi", WALLET_A, 8453), + preparedMetadata: { + totalSteps: 2, + }, + }; + + const step0 = step("corr-multi", 0, 43114); + const step1 = step("corr-multi", 1, 43114); + + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + + if (url.includes("/v1/transaction-evidence-steps?")) { + return response({ + data: [step0, step1], + }); + } + + if (url.endsWith("/v1/transaction-evidence/corr-multi")) { + return response({ + data: record, + }); + } + + if ( + url.endsWith( + "/v1/transaction-evidence-steps/corr-multi%3A0" + ) + ) { + return response({ + data: step0, + }); + } + + if ( + url.endsWith( + "/v1/transaction-evidence-steps/corr-multi%3A1" + ) + ) { + return response({ + data: step1, + }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const exported = + await exportTransactionEvidenceAdmin(43114); + + expect(exported.records).toHaveLength(1); + expect(exported.summary.correlationCount).toBe(1); + expect(exported.summary.stepCount).toBe(2); + }); + + it("preserves parent wallet filtering after chain-participation discovery", async () => { + const parentA = parent("corr-a", WALLET_A, 8453); + const parentB = parent("corr-b", WALLET_B, 8453); + const stepA = step("corr-a", 0, 43114); + const stepB = step("corr-b", 0, 43114); + + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + + if (url.includes("/v1/transaction-evidence-steps?")) { + return response({ + data: [stepA, stepB], + }); + } + + if (url.endsWith("/v1/transaction-evidence/corr-a")) { + return response({ + data: parentA, + }); + } + + if (url.endsWith("/v1/transaction-evidence/corr-b")) { + return response({ + data: parentB, + }); + } + + if ( + url.endsWith( + "/v1/transaction-evidence-steps/corr-a%3A0" + ) + ) { + return response({ + data: stepA, + }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const exported = + await exportTransactionEvidenceByWallet( + WALLET_A, + 43114 + ); + + expect(exported.records).toHaveLength(1); + expect(exported.records[0].correlationId).toBe("corr-a"); + expect(exported.records[0].intent.walletAddress).toBe( + WALLET_A + ); + }); + + it("returns no correlations when no step participates in the requested chain", async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + + if (url.includes("/v1/transaction-evidence-steps?")) { + return response({ + data: [], + }); + } + + throw new Error(`Unexpected request: ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + + const exported = + await exportTransactionEvidenceAdmin(43114); + + expect(exported.records).toEqual([]); + expect(exported.summary.correlationCount).toBe(0); + expect(exported.summary.stepCount).toBe(0); + }); +}); diff --git a/backend/src/shared/services/transaction-evidence.service.ts b/backend/src/shared/services/transaction-evidence.service.ts index 175fea7..f83af4f 100644 --- a/backend/src/shared/services/transaction-evidence.service.ts +++ b/backend/src/shared/services/transaction-evidence.service.ts @@ -536,30 +536,31 @@ async function gatewayList( } } -async function listStoredEvidenceByWallet( - walletAddress: string, - chainId = 43114 -): Promise { - const records: StoredEvidence[] = []; +async function listParticipatingCorrelationIdsByChain( + chainId: number +): Promise { + const correlationIds = new Set(); const take = 1000; let skip = 0; while (true) { const where = encodeURIComponent( - JSON.stringify({ - walletAddress: walletAddress.toLowerCase(), - chainId, - }) + JSON.stringify({ chainId }) ); const orderBy = encodeURIComponent( - JSON.stringify({ createdAt: "asc" }) + JSON.stringify({ + correlationId: "asc", + stepIndex: "asc", + }) ); - const result = await gatewayList( - `/v1/transaction-evidence?where=${where}&orderBy=${orderBy}&take=${take}&skip=${skip}` + const result = await gatewayList( + `/v1/transaction-evidence-steps?where=${where}&orderBy=${orderBy}&take=${take}&skip=${skip}` ); - records.push(...result.data); + for (const step of result.data) { + correlationIds.add(step.correlationId); + } if (result.data.length < take) { break; @@ -568,40 +569,58 @@ async function listStoredEvidenceByWallet( skip += take; } - return records; + return Array.from(correlationIds).sort(); } -async function listStoredEvidenceByChain( +async function listStoredEvidenceByWallet( + walletAddress: string, chainId = 43114 ): Promise { - const records: StoredEvidence[] = []; - const take = 1000; - let skip = 0; + const correlationIds = + await listParticipatingCorrelationIdsByChain(chainId); - while (true) { - const where = encodeURIComponent( - JSON.stringify({ chainId }) - ); - const orderBy = encodeURIComponent( - JSON.stringify({ createdAt: "asc" }) - ); + if (correlationIds.length === 0) { + return []; + } - const result = await gatewayList( - `/v1/transaction-evidence?where=${where}&orderBy=${orderBy}&take=${take}&skip=${skip}` - ); + const records: StoredEvidence[] = []; + const expectedWalletAddress = walletAddress.toLowerCase(); - records.push(...result.data); + for (const correlationId of correlationIds) { + const record = await gatewayGet( + `/v1/transaction-evidence/${encodeURIComponent(correlationId)}` + ); - if (result.data.length < take) { - break; + if ( + record && + record.walletAddress.toLowerCase() === expectedWalletAddress + ) { + records.push(record); } - - skip += take; } return records; } +async function listStoredEvidenceByChain( + chainId = 43114 +): Promise { + const correlationIds = + await listParticipatingCorrelationIdsByChain(chainId); + + const records = await Promise.all( + correlationIds.map((correlationId) => + gatewayGet( + `/v1/transaction-evidence/${encodeURIComponent(correlationId)}` + ) + ) + ); + + return records.filter( + (record): record is StoredEvidence => Boolean(record) + ); +} + async function gatewayPatch( entity: string, id: string, From 6730301e0b7b1c34b1ce6ebc5155029679a9a692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= Date: Sun, 30 Aug 2026 11:07:36 +0200 Subject: [PATCH 03/18] test: add reusable evidence preparation compliance --- .../evidence-bound-preparation.compliance.ts | 173 ++++++++++++++++++ ...dence-bound-preparation.compliance.test.ts | 100 ++++++++++ 2 files changed, 273 insertions(+) create mode 100644 backend/src/__tests__/shared/compliance/evidence-bound-preparation.compliance.ts create mode 100644 backend/src/__tests__/shared/evidence-bound-preparation.compliance.test.ts diff --git a/backend/src/__tests__/shared/compliance/evidence-bound-preparation.compliance.ts b/backend/src/__tests__/shared/compliance/evidence-bound-preparation.compliance.ts new file mode 100644 index 0000000..c3b468a --- /dev/null +++ b/backend/src/__tests__/shared/compliance/evidence-bound-preparation.compliance.ts @@ -0,0 +1,173 @@ +import { + beforeEach, + describe, + expect, + it, + type Mock, +} from "vitest"; + +export interface EvidencePreparationComplianceHarness { + name: string; + + prepareBoundary: (input: { + intent: Record; + prepare: () => Promise<{ + bundle: unknown; + metadata: Record; + }>; + }) => Promise; + + intent: Record; + bundle: unknown; + metadata: Record; + + correlationId: string; + preparedPayloadHash: string; + + persistIntentMock: Mock; + persistPreparedMock: Mock; + + reset: () => void; +} + +export function defineEvidencePreparationComplianceSuite( + harness: EvidencePreparationComplianceHarness +): void { + describe(`${harness.name} evidence preparation compliance`, () => { + beforeEach(() => { + harness.reset(); + }); + + it("commits intent before transaction preparation and prepared evidence before returning", async () => { + const events: string[] = []; + + harness.persistIntentMock.mockImplementation(async () => { + events.push("intent"); + }); + + const prepare = async () => { + events.push("prepare"); + + return { + bundle: harness.bundle, + metadata: harness.metadata, + }; + }; + + harness.persistPreparedMock.mockImplementation(async () => { + events.push("prepared-evidence"); + + return { + correlationId: harness.correlationId, + evidenceVersion: "1.0", + evidenceEnabled: true, + preparedPayloadHash: harness.preparedPayloadHash, + }; + }); + + const result = await harness.prepareBoundary({ + intent: harness.intent, + prepare, + }); + + events.push("returned"); + + expect(events).toEqual([ + "intent", + "prepare", + "prepared-evidence", + "returned", + ]); + + expect(result.bundle).toBe(harness.bundle); + }); + + it("does not expose or prepare an executable bundle when intent persistence fails", async () => { + const prepare = async () => ({ + bundle: harness.bundle, + metadata: harness.metadata, + }); + + harness.persistIntentMock.mockRejectedValue( + new Error("intent persistence failed") + ); + + let preparationCalled = false; + + await expect( + harness.prepareBoundary({ + intent: harness.intent, + prepare: async () => { + preparationCalled = true; + return prepare(); + }, + }) + ).rejects.toThrow("intent persistence failed"); + + expect(preparationCalled).toBe(false); + expect(harness.persistPreparedMock).not.toHaveBeenCalled(); + }); + + it("does not commit prepared evidence when transaction preparation fails", async () => { + await expect( + harness.prepareBoundary({ + intent: harness.intent, + prepare: async () => { + throw new Error("transaction preparation failed"); + }, + }) + ).rejects.toThrow("transaction preparation failed"); + + expect(harness.persistIntentMock).toHaveBeenCalledTimes(1); + expect(harness.persistPreparedMock).not.toHaveBeenCalled(); + }); + + it("does not return an executable bundle when prepared evidence persistence fails", async () => { + harness.persistPreparedMock.mockRejectedValue( + new Error("prepared evidence persistence failed") + ); + + await expect( + harness.prepareBoundary({ + intent: harness.intent, + prepare: async () => ({ + bundle: harness.bundle, + metadata: harness.metadata, + }), + }) + ).rejects.toThrow("prepared evidence persistence failed"); + + expect(harness.persistIntentMock).toHaveBeenCalledTimes(1); + expect(harness.persistPreparedMock).toHaveBeenCalledTimes(1); + }); + + it("preserves the exact prepared bundle and metadata instances", async () => { + const result = await harness.prepareBoundary({ + intent: harness.intent, + prepare: async () => ({ + bundle: harness.bundle, + metadata: harness.metadata, + }), + }); + + expect(result.bundle).toBe(harness.bundle); + expect(result.metadata).toBe(harness.metadata); + }); + + it("returns the committed evidence identity with the executable bundle", async () => { + const result = await harness.prepareBoundary({ + intent: harness.intent, + prepare: async () => ({ + bundle: harness.bundle, + metadata: harness.metadata, + }), + }); + + expect(result).toMatchObject({ + correlationId: harness.correlationId, + evidenceEnabled: true, + preparedPayloadHash: harness.preparedPayloadHash, + }); + }); + }); +} diff --git a/backend/src/__tests__/shared/evidence-bound-preparation.compliance.test.ts b/backend/src/__tests__/shared/evidence-bound-preparation.compliance.test.ts new file mode 100644 index 0000000..d4bcd71 --- /dev/null +++ b/backend/src/__tests__/shared/evidence-bound-preparation.compliance.test.ts @@ -0,0 +1,100 @@ +import { + vi, +} from "vitest"; + +import type { TransactionBundle } from "../../types/transaction"; + +const { + mockCreateEvidenceCorrelation, + mockPersistEvidenceIntent, + mockPersistPreparedEvidence, +} = vi.hoisted(() => ({ + mockCreateEvidenceCorrelation: vi.fn(), + mockPersistEvidenceIntent: vi.fn(), + mockPersistPreparedEvidence: vi.fn(), +})); + +vi.mock( + "../../shared/services/transaction-evidence.service", + () => ({ + createEvidenceCorrelation: mockCreateEvidenceCorrelation, + persistEvidenceIntent: mockPersistEvidenceIntent, + persistPreparedEvidence: mockPersistPreparedEvidence, + }) +); + +import { + prepareEvidenceBoundBundle, +} from "../../shared/services/evidence-bound-preparation.service"; + +import { + defineEvidencePreparationComplianceSuite, +} from "./compliance/evidence-bound-preparation.compliance"; + +const CORRELATION_ID = + "11111111-2222-4333-8444-555555555555"; + +const PREPARED_PAYLOAD_HASH = + `0x${"ab".repeat(32)}`; + +const bundle: TransactionBundle = { + steps: [ + { + to: "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12", + data: "0xdeadbeef", + value: "123456789", + chainId: 43114, + description: "Evidence compliance transaction", + }, + ], + totalSteps: 1, + summary: "Evidence compliance bundle", +}; + +const metadata = { + service: "COMPLIANCE", + selectedProvider: "test-provider", +}; + +const intent = { + action: "compliance", + chainId: 43114, + network: "avalanche-c-chain", + walletAddress: + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", +}; + +defineEvidencePreparationComplianceSuite({ + name: "generic preparation boundary", + + prepareBoundary: prepareEvidenceBoundBundle as any, + + intent, + bundle, + metadata, + + correlationId: CORRELATION_ID, + preparedPayloadHash: PREPARED_PAYLOAD_HASH, + + persistIntentMock: mockPersistEvidenceIntent, + persistPreparedMock: mockPersistPreparedEvidence, + + reset: () => { + vi.clearAllMocks(); + + mockCreateEvidenceCorrelation.mockReturnValue({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + enabled: true, + }); + + mockPersistEvidenceIntent.mockResolvedValue(undefined); + + mockPersistPreparedEvidence.mockResolvedValue({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + evidenceEnabled: true, + preparedPayloadHash: PREPARED_PAYLOAD_HASH, + }); + }, +}); From 5cd616ce685f5010e55bc4574af0a5d3bfdcd20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= Date: Sun, 30 Aug 2026 12:22:28 +0200 Subject: [PATCH 04/18] feat: evidence-bind Benqi supply --- .../lending-evidence-submission.test.ts | 107 +++++++ .../prepare-supply-evidence-bound.test.ts | 302 ++++++++++++++++++ .../controllers/avax-lending.controller.ts | 69 ++++ .../routes/avax-lending.routes.ts | 12 + .../usecases/prepare-supply.usecase.ts | 143 +++++---- 5 files changed, 578 insertions(+), 55 deletions(-) create mode 100644 backend/src/__tests__/modules/avax-lending/lending-evidence-submission.test.ts create mode 100644 backend/src/__tests__/modules/avax-lending/prepare-supply-evidence-bound.test.ts diff --git a/backend/src/__tests__/modules/avax-lending/lending-evidence-submission.test.ts b/backend/src/__tests__/modules/avax-lending/lending-evidence-submission.test.ts new file mode 100644 index 0000000..8faefd7 --- /dev/null +++ b/backend/src/__tests__/modules/avax-lending/lending-evidence-submission.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + mockSubmitAndVerifyEvidence, +} = vi.hoisted(() => ({ + mockSubmitAndVerifyEvidence: vi.fn(), +})); + +vi.mock( + "../../../shared/services/transaction-evidence.service", + () => ({ + submitAndVerifyEvidence: mockSubmitAndVerifyEvidence, + }) +); + +import { + submitEvidence, +} from "../../../modules/avax-lending/controllers/avax-lending.controller"; + +function invokeHandler( + handler: any, + req: any +): Promise<{ + json: ReturnType; + next: ReturnType; +}> { + return new Promise((resolve, reject) => { + const json = vi.fn(() => { + resolve({ json, next }); + }); + + const next = vi.fn((err?: unknown) => { + if (err) { + reject(err); + return; + } + + resolve({ json, next }); + }); + + handler( + req, + { json } as any, + next + ); + }); +} + +describe("Avalanche lending evidence submission", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockSubmitAndVerifyEvidence.mockResolvedValue({ + correlationId: "corr-123", + stepIndex: 0, + txHash: + "0x1111111111111111111111111111111111111111111111111111111111111111", + verified: true, + }); + }); + + it("submits lending evidence through the generic Avalanche verifier", async () => { + const txHash = + "0x1111111111111111111111111111111111111111111111111111111111111111"; + + const { json } = await invokeHandler( + submitEvidence, + { + params: { + correlationId: "corr-123", + }, + body: { + stepIndex: 0, + txHash, + executionMechanism: "thirdweb-client", + providerMetadata: { + provider: "thirdweb", + }, + }, + } + ); + + expect( + mockSubmitAndVerifyEvidence + ).toHaveBeenCalledTimes(1); + + expect( + mockSubmitAndVerifyEvidence + ).toHaveBeenCalledWith({ + correlationId: "corr-123", + stepIndex: 0, + txHash, + executionMechanism: "thirdweb-client", + providerMetadata: { + provider: "thirdweb", + }, + chain: "avalanche", + }); + + expect(json).toHaveBeenCalledWith({ + correlationId: "corr-123", + stepIndex: 0, + txHash, + verified: true, + }); + }); +}); diff --git a/backend/src/__tests__/modules/avax-lending/prepare-supply-evidence-bound.test.ts b/backend/src/__tests__/modules/avax-lending/prepare-supply-evidence-bound.test.ts new file mode 100644 index 0000000..575c0bf --- /dev/null +++ b/backend/src/__tests__/modules/avax-lending/prepare-supply-evidence-bound.test.ts @@ -0,0 +1,302 @@ +import { + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { ethers } from "ethers"; + +const EXECUTOR = + "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12"; + +const USER = + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +const QI_AVAX = + "0x5c0401e81bc07ca70fad469b451682c0d747ef1c"; + +const QI_USDCE = + "0xbeb5d47a3f720ec0a390d04b4d41ed7d9688bc7f"; + +const USDCE = + "0xa7d7079b0fead91f3e65f86e8915cb59c1a4c664"; + +const CORRELATION_ID = + "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + +const { + mockCheckAllowance, + mockEstimateGas, + mockCreateEvidenceCorrelation, + mockPersistEvidenceIntent, + mockPersistPreparedEvidence, +} = vi.hoisted(() => ({ + mockCheckAllowance: vi.fn(), + mockEstimateGas: vi.fn(), + mockCreateEvidenceCorrelation: vi.fn(), + mockPersistEvidenceIntent: vi.fn(), + mockPersistPreparedEvidence: vi.fn(), +})); + +vi.mock("../../../config/chains", () => ({ + getChainConfig: vi.fn(() => ({ + chainId: 43114, + name: "Avalanche", + contracts: { + panoramaExecutor: EXECUTOR, + }, + })), +})); + +vi.mock("../../../providers/chain.provider", () => ({ + getProvider: vi.fn(() => ({ + estimateGas: mockEstimateGas, + })), +})); + +vi.mock("../../../shared/services/avax.service", () => ({ + BENQI_MARKETS: { + qiAVAX: { + address: "0x5c0401e81bc07ca70fad469b451682c0d747ef1c", + symbol: "qiAVAX", + underlyingSymbol: "AVAX", + isNative: true, + }, + qiUSDCe: { + address: "0xbeb5d47a3f720ec0a390d04b4d41ed7d9688bc7f", + symbol: "qiUSDC", + underlyingSymbol: "USDC.e", + isNative: false, + }, + qiUSDT: { + address: "0xc9e5999b8e75c3feb117f6f73e664b9f3c8ca65c", + symbol: "qiUSDT", + underlyingSymbol: "USDT", + isNative: false, + }, + qiETH: { + address: "0x334AD834Cd4481BB02d09615E7c11a00579A7909", + symbol: "qiETH", + underlyingSymbol: "WETH.e", + isNative: false, + }, + }, + AVAX_TOKENS: { + USDCe: { + address: "0xa7d7079b0fead91f3e65f86e8915cb59c1a4c664", + }, + USDT: { + address: "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7", + }, + WETH: { + address: "0x49d5c2bdffac6ce2bfdb6640f4f80f226bc10bab", + }, + }, + avaxService: { + checkAllowance: mockCheckAllowance, + }, +})); + +vi.mock( + "../../../shared/services/transaction-evidence.service", + () => ({ + createEvidenceCorrelation: mockCreateEvidenceCorrelation, + persistEvidenceIntent: mockPersistEvidenceIntent, + persistPreparedEvidence: mockPersistPreparedEvidence, + }) +); + +import { + executePrepareSupply, +} from "../../../modules/avax-lending/usecases/prepare-supply.usecase"; + +import { + BENQI_SELECTORS, + ERC20_APPROVE_ABI, + PANORAMA_EXECUTOR_ABI_EXECUTE, +} from "../../../shared/bundle-builder"; + +describe("executePrepareSupply evidence-bound migration", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockEstimateGas.mockResolvedValue(100_000n); + mockCheckAllowance.mockResolvedValue(0n); + + mockCreateEvidenceCorrelation.mockReturnValue({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + enabled: true, + }); + + mockPersistEvidenceIntent.mockResolvedValue(undefined); + + mockPersistPreparedEvidence.mockImplementation( + async (_intent, bundle) => ({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + evidenceEnabled: true, + preparedPayloadHash: ethers.keccak256( + ethers.toUtf8Bytes(JSON.stringify(bundle)) + ), + }) + ); + }); + + it("preserves native AVAX supply semantics through the evidence boundary", async () => { + const amount = "1000000000000000000"; + + const result = await executePrepareSupply({ + userAddress: USER, + qTokenAddress: QI_AVAX, + amount, + }); + + expect(result.correlationId).toBe(CORRELATION_ID); + expect(result.evidenceVersion).toBe("1.0"); + expect(result.evidenceEnabled).toBe(true); + expect(result.preparedPayloadHash).toMatch(/^0x[a-fA-F0-9]{64}$/); + + expect(result.bundle.totalSteps).toBe(1); + expect(result.bundle.steps).toHaveLength(1); + + const supply = result.bundle.steps[0]; + + expect(supply.to).toBe(EXECUTOR); + expect(supply.value).toBe(amount); + expect(supply.chainId).toBe(43114); + + const executorInterface = + new ethers.Interface(PANORAMA_EXECUTOR_ABI_EXECUTE); + + const decoded = + executorInterface.decodeFunctionData( + "execute", + supply.data + ); + + expect(decoded[0]).toBe( + ethers.keccak256(ethers.toUtf8Bytes("benqi")) + ); + + expect(decoded[1]).toBe(BENQI_SELECTORS.SUPPLY_AVAX); + expect(decoded[2]).toHaveLength(0); + + const adapterDecoded = + ethers.AbiCoder.defaultAbiCoder().decode( + ["address"], + decoded[4] + ); + + expect(adapterDecoded[0]).toBe(USER); + + expect(result.metadata).toEqual({ + action: "supply", + qTokenAddress: QI_AVAX, + qTokenSymbol: "qiAVAX", + underlyingSymbol: "AVAX", + amount, + isNative: true, + }); + + expect(mockCheckAllowance).not.toHaveBeenCalled(); + expect(mockPersistEvidenceIntent).toHaveBeenCalledTimes(1); + expect(mockPersistPreparedEvidence).toHaveBeenCalledTimes(1); + + expect( + mockPersistPreparedEvidence.mock.calls[0][1] + ).toBe(result.bundle); + }); + + it("preserves ERC-20 approval then Benqi supply ordering and semantics", async () => { + const amount = "1000000"; + + const result = await executePrepareSupply({ + userAddress: USER, + qTokenAddress: QI_USDCE, + amount, + }); + + expect(result.correlationId).toBe(CORRELATION_ID); + expect(result.evidenceEnabled).toBe(true); + + expect(result.bundle.totalSteps).toBe(2); + expect(result.bundle.steps).toHaveLength(2); + + const approval = result.bundle.steps[0]; + const supply = result.bundle.steps[1]; + + expect(approval.to).toBe(USDCE); + expect(approval.value).toBe("0"); + expect(approval.chainId).toBe(43114); + + const approveInterface = + new ethers.Interface(ERC20_APPROVE_ABI); + + const approveDecoded = + approveInterface.decodeFunctionData( + "approve", + approval.data + ); + + expect(approveDecoded[0]).toBe(EXECUTOR); + expect(approveDecoded[1]).toBe(1_000_000n); + + expect(supply.to).toBe(EXECUTOR); + expect(supply.value).toBe("0"); + expect(supply.chainId).toBe(43114); + + const executorInterface = + new ethers.Interface(PANORAMA_EXECUTOR_ABI_EXECUTE); + + const decoded = + executorInterface.decodeFunctionData( + "execute", + supply.data + ); + + expect(decoded[0]).toBe( + ethers.keccak256(ethers.toUtf8Bytes("benqi")) + ); + + expect(decoded[1]).toBe(BENQI_SELECTORS.SUPPLY); + + expect(decoded[2]).toHaveLength(1); + expect(decoded[2][0].token.toLowerCase()).toBe(USDCE.toLowerCase()); + expect(decoded[2][0].amount).toBe(1_000_000n); + + const adapterDecoded = + ethers.AbiCoder.defaultAbiCoder().decode( + ["address", "uint256", "address"], + decoded[4] + ); + + expect(adapterDecoded[0].toLowerCase()).toBe(QI_USDCE.toLowerCase()); + expect(adapterDecoded[1]).toBe(1_000_000n); + expect(adapterDecoded[2]).toBe(USER); + + expect(mockCheckAllowance).toHaveBeenCalledWith( + USDCE, + USER, + EXECUTOR, + 1_000_000n + ); + + expect(result.metadata).toEqual({ + action: "supply", + qTokenAddress: QI_USDCE, + qTokenSymbol: "qiUSDC", + underlyingSymbol: "USDC.e", + amount, + isNative: false, + }); + + expect(mockPersistEvidenceIntent).toHaveBeenCalledTimes(1); + expect(mockPersistPreparedEvidence).toHaveBeenCalledTimes(1); + + expect( + mockPersistPreparedEvidence.mock.calls[0][1] + ).toBe(result.bundle); + }); +}); diff --git a/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts b/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts index 9d6f3f1..3b23f2c 100644 --- a/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts +++ b/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts @@ -8,6 +8,10 @@ import { getEnabledMarkets } from "../config/avax-lending-markets"; import { avaxService } from "../../../shared/services/avax.service"; import { getContract } from "../../../providers/chain.provider"; import { BENQI_TOKEN_ABI } from "../../../utils/abi"; +import { + submitAndVerifyEvidence, +} from "../../../shared/services/transaction-evidence.service"; +import { AppError } from "../../../shared/errorCodes"; export const getMarkets = asyncHandler(async (_req: Request, res: Response) => { const markets = getEnabledMarkets(); @@ -61,6 +65,71 @@ export const prepareSupply = asyncHandler(async (req: Request, res: Response) => res.json(result); }); +export const submitEvidence = asyncHandler(async (req: Request, res: Response) => { + const { correlationId } = req.params; + const { + stepIndex, + txHash, + executionMechanism, + providerMetadata, + } = req.body; + + if (!correlationId || typeof correlationId !== "string") { + throw new AppError( + "MISSING_FIELD", + "correlationId is required" + ); + } + + if (!Number.isInteger(stepIndex) || stepIndex < 0) { + throw new AppError( + "MISSING_FIELD", + "stepIndex must be a non-negative integer" + ); + } + + if ( + typeof txHash !== "string" || + !/^0x[a-fA-F0-9]{64}$/.test(txHash) + ) { + throw new AppError("INVALID_TX_HASH"); + } + + try { + const result = await submitAndVerifyEvidence({ + correlationId, + stepIndex, + txHash, + executionMechanism, + providerMetadata, + chain: "avalanche", + }); + + res.json(result); + } catch (err) { + if (err instanceof AppError) { + throw err; + } + + const message = + err instanceof Error + ? err.message + : "Evidence verification failed"; + + if (message.includes("not found")) { + throw new AppError( + "TRANSACTION_NOT_FOUND", + message + ); + } + + throw new AppError( + "INTERNAL_ERROR", + message + ); + } +}); + export const prepareRedeem = asyncHandler(async (req: Request, res: Response) => { const result = await executePrepareRedeem({ userAddress: req.body.userAddress, diff --git a/backend/src/modules/avax-lending/routes/avax-lending.routes.ts b/backend/src/modules/avax-lending/routes/avax-lending.routes.ts index 65a8daa..bc826fd 100644 --- a/backend/src/modules/avax-lending/routes/avax-lending.routes.ts +++ b/backend/src/modules/avax-lending/routes/avax-lending.routes.ts @@ -38,6 +38,18 @@ avaxLendingRoutes.post( ctrl.prepareSupply ); +/** + * POST /avax/lending/evidence/:correlationId/submissions + * Records a submitted lending transaction and independently verifies + * its Avalanche receipt against the prepared evidence commitment. + * + * Body: { stepIndex, txHash, executionMechanism?, providerMetadata? } + */ +avaxLendingRoutes.post( + "/evidence/:correlationId/submissions", + ctrl.submitEvidence +); + /** * POST /avax/lending/prepare-redeem * Prepares a TransactionBundle to redeem qTokens for underlying. diff --git a/backend/src/modules/avax-lending/usecases/prepare-supply.usecase.ts b/backend/src/modules/avax-lending/usecases/prepare-supply.usecase.ts index be13346..3501376 100644 --- a/backend/src/modules/avax-lending/usecases/prepare-supply.usecase.ts +++ b/backend/src/modules/avax-lending/usecases/prepare-supply.usecase.ts @@ -6,6 +6,9 @@ import { BundleBuilder, BENQI_SELECTORS } from "../../../shared/bundle-builder"; import { TransactionBundle } from "../../../types/transaction"; import { AppError } from "../../../shared/errorCodes"; import { getMarketByQToken } from "../config/avax-lending-markets"; +import { + prepareEvidenceBoundBundle, +} from "../../../shared/services/evidence-bound-preparation.service"; export interface PrepareSupplyRequest { userAddress: string; @@ -14,6 +17,10 @@ export interface PrepareSupplyRequest { } export interface PrepareSupplyResponse { + correlationId: string; + evidenceVersion: string; + evidenceEnabled: boolean; + preparedPayloadHash: string; bundle: TransactionBundle; metadata: { action: "supply"; @@ -36,65 +43,91 @@ export async function executePrepareSupply(req: PrepareSupplyRequest): Promise

{ + const protocolId = encodeProtocolId("benqi"); + const builder = new BundleBuilder(chain.chainId); + const deadline = getDeadline(20); + + if (market.isNative) { + // supplyAVAX(address user) — native AVAX sent as msg.value + const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( + ["address"], + [req.userAddress] + ); + + builder.addExecute( + protocolId, + BENQI_SELECTORS.SUPPLY_AVAX, + [], + deadline, + adapterData, + amount, + executorAddr, + `Supply ${ethers.formatEther(amount)} AVAX to Benqi` + ); + } else { + // approve underlying → executor + const allowance = await avaxService.checkAllowance( + market.underlyingAddress!, + req.userAddress, + executorAddr, + amount + ); - if (market.isNative) { - // supplyAVAX(address user) — native AVAX sent as msg.value - const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( - ["address"], - [req.userAddress] - ); + builder.addApproveIfNeeded( + market.underlyingAddress!, + executorAddr, + allowance, + amount, + `Approve ${market.underlyingSymbol} for PanoramaExecutor` + ); - builder.addExecute( - protocolId, - BENQI_SELECTORS.SUPPLY_AVAX, - [], - deadline, - adapterData, - amount, - executorAddr, - `Supply ${ethers.formatEther(amount)} AVAX to Benqi` - ); - } else { - // approve underlying → executor - const allowance = await avaxService.checkAllowance(market.underlyingAddress!, req.userAddress, executorAddr, amount); - builder.addApproveIfNeeded( - market.underlyingAddress!, - executorAddr, - allowance, - amount, - `Approve ${market.underlyingSymbol} for PanoramaExecutor` - ); + // supply(address qToken, uint256 amount, address user) + const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( + ["address", "uint256", "address"], + [req.qTokenAddress, amount, req.userAddress] + ); - // supply(address qToken, uint256 amount, address user) - const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( - ["address", "uint256", "address"], - [req.qTokenAddress, amount, req.userAddress] - ); + builder.addExecute( + protocolId, + BENQI_SELECTORS.SUPPLY, + [{ token: market.underlyingAddress!, amount }], + deadline, + adapterData, + 0n, + executorAddr, + `Supply ${market.underlyingSymbol} to Benqi` + ); + } - builder.addExecute( - protocolId, - BENQI_SELECTORS.SUPPLY, - [{ token: market.underlyingAddress!, amount }], - deadline, - adapterData, - 0n, - executorAddr, - `Supply ${market.underlyingSymbol} to Benqi` - ); - } + const bundle = await builder.buildWithGas( + `Supply ${market.underlyingSymbol} to Benqi on Avalanche`, + req.userAddress + ); - return { - bundle: await builder.buildWithGas(`Supply ${market.underlyingSymbol} to Benqi on Avalanche`, req.userAddress), - metadata: { - action: "supply", - qTokenAddress: req.qTokenAddress, - qTokenSymbol: market.qTokenSymbol, - underlyingSymbol: market.underlyingSymbol, - amount: amount.toString(), - isNative: market.isNative, + const metadata = { + action: "supply" as const, + qTokenAddress: req.qTokenAddress, + qTokenSymbol: market.qTokenSymbol, + underlyingSymbol: market.underlyingSymbol, + amount: amount.toString(), + isNative: market.isNative, + }; + + return { + bundle, + metadata, + }; }, - }; + }); } From 556258a622c05cd87f045f1f64fa3749c8e41a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= Date: Sun, 30 Aug 2026 13:36:18 +0200 Subject: [PATCH 05/18] feat: evidence-bind Benqi redeem --- .../prepare-redeem-evidence-bound.test.ts | 429 ++++++++++++++++++ .../routes/avax-lending.routes.ts | 3 +- .../usecases/prepare-redeem.usecase.ts | 217 ++++++--- 3 files changed, 574 insertions(+), 75 deletions(-) create mode 100644 backend/src/__tests__/modules/avax-lending/prepare-redeem-evidence-bound.test.ts diff --git a/backend/src/__tests__/modules/avax-lending/prepare-redeem-evidence-bound.test.ts b/backend/src/__tests__/modules/avax-lending/prepare-redeem-evidence-bound.test.ts new file mode 100644 index 0000000..b8b5ef1 --- /dev/null +++ b/backend/src/__tests__/modules/avax-lending/prepare-redeem-evidence-bound.test.ts @@ -0,0 +1,429 @@ +import { + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { ethers } from "ethers"; + +const EXECUTOR = + "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12"; + +const USER = + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +const QI_AVAX = + "0x5c0401e81bc07ca70fad469b451682c0d747ef1c"; + +const QI_USDCE = + "0xbeb5d47a3f720ec0a390d04b4d41ed7d9688bc7f"; + +const CORRELATION_ID = + "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff"; + +const { + mockCheckAllowance, + mockEstimateGas, + mockExchangeRateStored, + mockBalanceOf, + mockCreateEvidenceCorrelation, + mockPersistEvidenceIntent, + mockPersistPreparedEvidence, +} = vi.hoisted(() => ({ + mockCheckAllowance: vi.fn(), + mockEstimateGas: vi.fn(), + mockExchangeRateStored: vi.fn(), + mockBalanceOf: vi.fn(), + mockCreateEvidenceCorrelation: vi.fn(), + mockPersistEvidenceIntent: vi.fn(), + mockPersistPreparedEvidence: vi.fn(), +})); + +vi.mock("../../../config/chains", () => ({ + getChainConfig: vi.fn(() => ({ + chainId: 43114, + name: "Avalanche", + contracts: { + panoramaExecutor: + "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12", + }, + })), +})); + +vi.mock("../../../providers/chain.provider", () => ({ + getProvider: vi.fn(() => ({ + estimateGas: mockEstimateGas, + })), + getContract: vi.fn(() => ({ + exchangeRateStored: mockExchangeRateStored, + balanceOf: mockBalanceOf, + })), +})); + +vi.mock("../../../shared/services/avax.service", () => ({ + BENQI_MARKETS: { + qiAVAX: { + address: + "0x5c0401e81bc07ca70fad469b451682c0d747ef1c", + symbol: "qiAVAX", + underlyingSymbol: "AVAX", + isNative: true, + }, + qiUSDCe: { + address: + "0xbeb5d47a3f720ec0a390d04b4d41ed7d9688bc7f", + symbol: "qiUSDC", + underlyingSymbol: "USDC.e", + isNative: false, + }, + qiUSDT: { + address: + "0xc9e5999b8e75c3feb117f6f73e664b9f3c8ca65c", + symbol: "qiUSDT", + underlyingSymbol: "USDT", + isNative: false, + }, + qiETH: { + address: + "0x334ad834cd4481bb02d09615e7c11a00579a7909", + symbol: "qiETH", + underlyingSymbol: "WETH.e", + isNative: false, + }, + }, + AVAX_TOKENS: { + USDCe: { + address: + "0xa7d7079b0fead91f3e65f86e8915cb59c1a4c664", + }, + USDT: { + address: + "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7", + }, + WETH: { + address: + "0x49d5c2bdffac6ce2bfdb6640f4f80f226bc10bab", + }, + }, + avaxService: { + checkAllowance: mockCheckAllowance, + }, +})); + +vi.mock( + "../../../shared/services/transaction-evidence.service", + () => ({ + createEvidenceCorrelation: mockCreateEvidenceCorrelation, + persistEvidenceIntent: mockPersistEvidenceIntent, + persistPreparedEvidence: mockPersistPreparedEvidence, + }) +); + +import { + executePrepareRedeem, +} from "../../../modules/avax-lending/usecases/prepare-redeem.usecase"; + +import { + BENQI_SELECTORS, + ERC20_APPROVE_ABI, + PANORAMA_EXECUTOR_ABI_EXECUTE, +} from "../../../shared/bundle-builder"; + +describe("executePrepareRedeem evidence-bound migration", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockEstimateGas.mockResolvedValue(100_000n); + + mockCreateEvidenceCorrelation.mockReturnValue({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + enabled: true, + }); + + mockPersistEvidenceIntent.mockResolvedValue(undefined); + + mockPersistPreparedEvidence.mockImplementation( + async (_intent, bundle) => ({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + evidenceEnabled: true, + preparedPayloadHash: ethers.keccak256( + ethers.toUtf8Bytes(JSON.stringify(bundle)) + ), + }) + ); + }); + + it("preserves native qiAVAX redeem semantics through the evidence boundary", async () => { + const underlyingAmount = "1000000000000000000"; + const exchangeRate = + 200000000000000000000000000n; + const expectedQTokenAmount = 5_000_000_000n; + + mockExchangeRateStored.mockResolvedValue(exchangeRate); + mockBalanceOf.mockResolvedValue(6_000_000_000n); + mockCheckAllowance.mockResolvedValue( + expectedQTokenAmount + ); + + const result = await executePrepareRedeem({ + userAddress: USER, + qTokenAddress: QI_AVAX, + amount: underlyingAmount, + }); + + expect(result.correlationId).toBe(CORRELATION_ID); + expect(result.evidenceVersion).toBe("1.0"); + expect(result.evidenceEnabled).toBe(true); + expect(result.preparedPayloadHash).toMatch( + /^0x[a-fA-F0-9]{64}$/ + ); + + expect(result.bundle.totalSteps).toBe(1); + expect(result.bundle.steps).toHaveLength(1); + + const redeem = result.bundle.steps[0]; + + expect(redeem.to).toBe(EXECUTOR); + expect(redeem.value).toBe("0"); + expect(redeem.chainId).toBe(43114); + + const executorInterface = + new ethers.Interface( + PANORAMA_EXECUTOR_ABI_EXECUTE + ); + + const decoded = + executorInterface.decodeFunctionData( + "execute", + redeem.data + ); + + expect(decoded[0]).toBe( + ethers.keccak256( + ethers.toUtf8Bytes("benqi") + ) + ); + + expect(decoded[1]).toBe( + BENQI_SELECTORS.REDEEM_AVAX + ); + + expect(decoded[2]).toHaveLength(1); + expect( + decoded[2][0].token.toLowerCase() + ).toBe(QI_AVAX.toLowerCase()); + expect(decoded[2][0].amount).toBe( + expectedQTokenAmount + ); + + const adapterDecoded = + ethers.AbiCoder.defaultAbiCoder().decode( + ["uint256", "address"], + decoded[4] + ); + + expect(adapterDecoded[0]).toBe( + expectedQTokenAmount + ); + expect(adapterDecoded[1]).toBe(USER); + + expect(result.metadata).toEqual({ + action: "redeem", + qTokenAddress: QI_AVAX, + qTokenSymbol: "qiAVAX", + underlyingSymbol: "AVAX", + qTokenAmount: + expectedQTokenAmount.toString(), + isNative: true, + }); + + expect(mockPersistEvidenceIntent) + .toHaveBeenCalledTimes(1); + + expect(mockPersistPreparedEvidence) + .toHaveBeenCalledTimes(1); + + expect( + mockPersistPreparedEvidence.mock.calls[0][1] + ).toBe(result.bundle); + + // Evidence ordering invariant: + // T1 persist intent → T2 read/prepare → T3 persist commitment. + expect( + mockPersistEvidenceIntent.mock.invocationCallOrder[0] + ).toBeLessThan( + mockExchangeRateStored.mock.invocationCallOrder[0] + ); + + expect( + mockPersistEvidenceIntent.mock.invocationCallOrder[0] + ).toBeLessThan( + mockBalanceOf.mock.invocationCallOrder[0] + ); + + expect( + mockExchangeRateStored.mock.invocationCallOrder[0] + ).toBeLessThan( + mockPersistPreparedEvidence.mock.invocationCallOrder[0] + ); + + expect( + mockBalanceOf.mock.invocationCallOrder[0] + ).toBeLessThan( + mockPersistPreparedEvidence.mock.invocationCallOrder[0] + ); + }); + + it("preserves qToken approval then ERC-20 redeem ordering and semantics", async () => { + const underlyingAmount = "1000000"; + const exchangeRate = + 200000000000000n; + const expectedQTokenAmount = 5_000_000_000n; + + mockExchangeRateStored.mockResolvedValue(exchangeRate); + mockBalanceOf.mockResolvedValue(10_000_000_000n); + mockCheckAllowance.mockResolvedValue(0n); + + const result = await executePrepareRedeem({ + userAddress: USER, + qTokenAddress: QI_USDCE, + amount: underlyingAmount, + }); + + expect(result.correlationId).toBe(CORRELATION_ID); + expect(result.evidenceEnabled).toBe(true); + + expect(result.bundle.totalSteps).toBe(2); + expect(result.bundle.steps).toHaveLength(2); + + const approval = result.bundle.steps[0]; + const redeem = result.bundle.steps[1]; + + expect(approval.to.toLowerCase()).toBe( + QI_USDCE.toLowerCase() + ); + expect(approval.value).toBe("0"); + expect(approval.chainId).toBe(43114); + + const approveInterface = + new ethers.Interface(ERC20_APPROVE_ABI); + + const approveDecoded = + approveInterface.decodeFunctionData( + "approve", + approval.data + ); + + expect( + approveDecoded[0].toLowerCase() + ).toBe(EXECUTOR.toLowerCase()); + + expect(approveDecoded[1]).toBe( + expectedQTokenAmount + ); + + expect(redeem.to).toBe(EXECUTOR); + expect(redeem.value).toBe("0"); + expect(redeem.chainId).toBe(43114); + + const executorInterface = + new ethers.Interface( + PANORAMA_EXECUTOR_ABI_EXECUTE + ); + + const decoded = + executorInterface.decodeFunctionData( + "execute", + redeem.data + ); + + expect(decoded[1]).toBe( + BENQI_SELECTORS.REDEEM + ); + + expect(decoded[2]).toHaveLength(1); + expect( + decoded[2][0].token.toLowerCase() + ).toBe(QI_USDCE.toLowerCase()); + expect(decoded[2][0].amount).toBe( + expectedQTokenAmount + ); + + const adapterDecoded = + ethers.AbiCoder.defaultAbiCoder().decode( + ["address", "uint256", "address"], + decoded[4] + ); + + expect( + adapterDecoded[0].toLowerCase() + ).toBe(QI_USDCE.toLowerCase()); + + expect(adapterDecoded[1]).toBe( + expectedQTokenAmount + ); + + expect(adapterDecoded[2]).toBe(USER); + + expect(mockCheckAllowance).toHaveBeenCalledWith( + QI_USDCE, + USER, + EXECUTOR, + expectedQTokenAmount + ); + + expect(mockPersistPreparedEvidence) + .toHaveBeenCalledTimes(1); + }); + + it("caps the derived qToken amount at the wallet's actual balance", async () => { + const underlyingAmount = "1000000000000000000"; + const walletQTokenBalance = 123_456_789n; + + mockExchangeRateStored.mockResolvedValue( + 200000000000000000000000000n + ); + + mockBalanceOf.mockResolvedValue( + walletQTokenBalance + ); + + mockCheckAllowance.mockResolvedValue( + walletQTokenBalance + ); + + const result = await executePrepareRedeem({ + userAddress: USER, + qTokenAddress: QI_AVAX, + amount: underlyingAmount, + }); + + expect(result.metadata.qTokenAmount).toBe( + walletQTokenBalance.toString() + ); + + const executorInterface = + new ethers.Interface( + PANORAMA_EXECUTOR_ABI_EXECUTE + ); + + const decoded = + executorInterface.decodeFunctionData( + "execute", + result.bundle.steps[0].data + ); + + const adapterDecoded = + ethers.AbiCoder.defaultAbiCoder().decode( + ["uint256", "address"], + decoded[4] + ); + + expect(adapterDecoded[0]).toBe( + walletQTokenBalance + ); + }); +}); diff --git a/backend/src/modules/avax-lending/routes/avax-lending.routes.ts b/backend/src/modules/avax-lending/routes/avax-lending.routes.ts index bc826fd..11370b7 100644 --- a/backend/src/modules/avax-lending/routes/avax-lending.routes.ts +++ b/backend/src/modules/avax-lending/routes/avax-lending.routes.ts @@ -55,7 +55,8 @@ avaxLendingRoutes.post( * Prepares a TransactionBundle to redeem qTokens for underlying. * Steps: [approve qToken] + [redeem / redeemAVAX] * - * Body: { userAddress, qTokenAddress, qTokenAmount } + * Body: { userAddress, qTokenAddress, amount } + * amount is the requested underlying amount in underlying base units. */ avaxLendingRoutes.post( "/prepare-redeem", diff --git a/backend/src/modules/avax-lending/usecases/prepare-redeem.usecase.ts b/backend/src/modules/avax-lending/usecases/prepare-redeem.usecase.ts index 809efb4..b2e1422 100644 --- a/backend/src/modules/avax-lending/usecases/prepare-redeem.usecase.ts +++ b/backend/src/modules/avax-lending/usecases/prepare-redeem.usecase.ts @@ -1,6 +1,7 @@ import { ethers } from "ethers"; import { getChainConfig } from "../../../config/chains"; import { avaxService } from "../../../shared/services/avax.service"; +import { prepareEvidenceBoundBundle } from "../../../shared/services/evidence-bound-preparation.service"; import { encodeProtocolId, getDeadline } from "../../../utils/encoding"; import { BundleBuilder, BENQI_SELECTORS } from "../../../shared/bundle-builder"; import { TransactionBundle } from "../../../types/transaction"; @@ -16,7 +17,11 @@ export interface PrepareRedeemRequest { } export interface PrepareRedeemResponse { - bundle: TransactionBundle; + correlationId: string; + evidenceVersion: string; + evidenceEnabled: boolean; + preparedPayloadHash: string; + bundle: TransactionBundle; metadata: { action: "redeem"; qTokenAddress: string; @@ -38,78 +43,142 @@ export async function executePrepareRedeem(req: PrepareRedeemRequest): Promise

, - qTokenContract.balanceOf(req.userAddress) as Promise, - ]); - - let qTokenAmount = (underlyingAmount * BigInt(1e18)) / exchangeRate; - // Cap at user's actual balance (handles rounding when user wants full withdrawal) - if (qTokenAmount > userQTokenBalance) qTokenAmount = userQTokenBalance; - if (qTokenAmount <= 0n) throw new AppError("INVALID_AMOUNT", "Derived qTokenAmount is zero — position may be empty"); - - const protocolId = encodeProtocolId("benqi"); - const builder = new BundleBuilder(chain.chainId); - const deadline = getDeadline(20); - - // Approve qToken → executor so it can pull qTokens to the proxy - const allowance = await avaxService.checkAllowance(req.qTokenAddress, req.userAddress, executorAddr, qTokenAmount); - builder.addApproveIfNeeded( - req.qTokenAddress, - executorAddr, - allowance, - qTokenAmount, - `Approve ${market.qTokenSymbol} for PanoramaExecutor` - ); - - if (market.isNative) { - // redeemAVAX(uint256 qTokenAmount, address recipient) - const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( - ["uint256", "address"], - [qTokenAmount, req.userAddress] - ); - - builder.addExecute( - protocolId, - BENQI_SELECTORS.REDEEM_AVAX, - [{ token: req.qTokenAddress, amount: qTokenAmount }], - deadline, - adapterData, - 0n, - executorAddr, - `Redeem ${market.qTokenSymbol} for AVAX via Benqi` - ); - } else { - // redeem(address qToken, uint256 qTokenAmount, address recipient) - const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( - ["address", "uint256", "address"], - [req.qTokenAddress, qTokenAmount, req.userAddress] - ); - - builder.addExecute( - protocolId, - BENQI_SELECTORS.REDEEM, - [{ token: req.qTokenAddress, amount: qTokenAmount }], - deadline, - adapterData, - 0n, - executorAddr, - `Redeem ${market.qTokenSymbol} for ${market.underlyingSymbol} via Benqi` - ); - } - - return { - bundle: await builder.buildWithGas(`Redeem ${market.qTokenSymbol} on Avalanche`, req.userAddress), - metadata: { - action: "redeem", - qTokenAddress: req.qTokenAddress, - qTokenSymbol: market.qTokenSymbol, - underlyingSymbol: market.underlyingSymbol, - qTokenAmount: qTokenAmount.toString(), - isNative: market.isNative, + return prepareEvidenceBoundBundle({ + intent: { + action: "redeem", + chainId: chain.chainId, + network: "avalanche-c-chain", + walletAddress: req.userAddress, + assetIn: req.qTokenAddress, + assetOut: + market.underlyingAddress ?? + market.underlyingSymbol, + amountRaw: req.amount, }, - }; + prepare: async () => { + // Convert requested underlying amount → qToken amount using + // Benqi's current exchange rate. + // Compound-fork formula: + // qTokenAmount = underlyingWei * 1e18 / exchangeRate + const qTokenContract = getContract( + req.qTokenAddress, + BENQI_TOKEN_ABI, + "avalanche" + ); + + const [ + exchangeRate, + userQTokenBalance, + ] = await Promise.all([ + qTokenContract.exchangeRateStored() as Promise, + qTokenContract.balanceOf( + req.userAddress + ) as Promise, + ]); + + let qTokenAmount = + (underlyingAmount * BigInt(1e18)) / + exchangeRate; + + // Cap at the wallet's actual balance to handle full-withdrawal + // rounding at the protocol exchange rate. + if (qTokenAmount > userQTokenBalance) { + qTokenAmount = userQTokenBalance; + } + + if (qTokenAmount <= 0n) { + throw new AppError( + "INVALID_AMOUNT", + "Derived qTokenAmount is zero — position may be empty" + ); + } + + const protocolId = encodeProtocolId("benqi"); + const builder = new BundleBuilder(chain.chainId); + const deadline = getDeadline(20); + + // Approve qToken → executor so it can pull qTokens to the proxy + const allowance = await avaxService.checkAllowance( + req.qTokenAddress, + req.userAddress, + executorAddr, + qTokenAmount + ); + + builder.addApproveIfNeeded( + req.qTokenAddress, + executorAddr, + allowance, + qTokenAmount, + `Approve ${market.qTokenSymbol} for PanoramaExecutor` + ); + + if (market.isNative) { + // redeemAVAX(uint256 qTokenAmount, address recipient) + const adapterData = + ethers.AbiCoder.defaultAbiCoder().encode( + ["uint256", "address"], + [qTokenAmount, req.userAddress] + ); + + builder.addExecute( + protocolId, + BENQI_SELECTORS.REDEEM_AVAX, + [{ + token: req.qTokenAddress, + amount: qTokenAmount, + }], + deadline, + adapterData, + 0n, + executorAddr, + `Redeem ${market.qTokenSymbol} for AVAX via Benqi` + ); + } else { + // redeem(address qToken, uint256 qTokenAmount, address recipient) + const adapterData = + ethers.AbiCoder.defaultAbiCoder().encode( + ["address", "uint256", "address"], + [ + req.qTokenAddress, + qTokenAmount, + req.userAddress, + ] + ); + + builder.addExecute( + protocolId, + BENQI_SELECTORS.REDEEM, + [{ + token: req.qTokenAddress, + amount: qTokenAmount, + }], + deadline, + adapterData, + 0n, + executorAddr, + `Redeem ${market.qTokenSymbol} for ${market.underlyingSymbol} via Benqi` + ); + } + + const bundle = await builder.buildWithGas( + `Redeem ${market.qTokenSymbol} on Avalanche`, + req.userAddress + ); + + const metadata = { + action: "redeem" as const, + qTokenAddress: req.qTokenAddress, + qTokenSymbol: market.qTokenSymbol, + underlyingSymbol: market.underlyingSymbol, + qTokenAmount: qTokenAmount.toString(), + isNative: market.isNative, + }; + + return { + bundle, + metadata, + }; + }, + }); } From 0b6b7d9717d8652e899c59ec644f3a5112f2ac8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= Date: Sun, 30 Aug 2026 14:02:59 +0200 Subject: [PATCH 06/18] feat: evidence-bind Benqi borrow --- .../prepare-borrow-evidence-bound.test.ts | 295 ++++++++++++++++++ .../usecases/prepare-borrow.usecase.ts | 120 ++++--- 2 files changed, 368 insertions(+), 47 deletions(-) create mode 100644 backend/src/__tests__/modules/avax-lending/prepare-borrow-evidence-bound.test.ts diff --git a/backend/src/__tests__/modules/avax-lending/prepare-borrow-evidence-bound.test.ts b/backend/src/__tests__/modules/avax-lending/prepare-borrow-evidence-bound.test.ts new file mode 100644 index 0000000..7396067 --- /dev/null +++ b/backend/src/__tests__/modules/avax-lending/prepare-borrow-evidence-bound.test.ts @@ -0,0 +1,295 @@ +import { + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { ethers } from "ethers"; + +const EXECUTOR = + "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12"; + +const USER = + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +const QI_AVAX = + "0x5c0401e81bc07ca70fad469b451682c0d747ef1c"; + +const QI_USDCE = + "0xbeb5d47a3f720ec0a390d04b4d41ed7d9688bc7f"; + +const USDC_E = + "0xa7d7079b0fead91f3e65f86e8915cb59c1a4c664"; + +const CORRELATION_ID = + "cccccccc-dddd-4eee-8fff-aaaaaaaaaaaa"; + +const { + mockEstimateGas, + mockCreateEvidenceCorrelation, + mockPersistEvidenceIntent, + mockPersistPreparedEvidence, +} = vi.hoisted(() => ({ + mockEstimateGas: vi.fn(), + mockCreateEvidenceCorrelation: vi.fn(), + mockPersistEvidenceIntent: vi.fn(), + mockPersistPreparedEvidence: vi.fn(), +})); + +vi.mock("../../../config/chains", () => ({ + getChainConfig: vi.fn(() => ({ + chainId: 43114, + name: "Avalanche", + contracts: { + panoramaExecutor: + "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12", + }, + })), +})); + +vi.mock("../../../providers/chain.provider", () => ({ + getProvider: vi.fn(() => ({ + estimateGas: mockEstimateGas, + })), +})); + +vi.mock( + "../../../modules/avax-lending/config/avax-lending-markets", + () => ({ + getMarketByQToken: vi.fn((address: string) => { + const normalized = address.toLowerCase(); + + if (normalized === QI_AVAX) { + return { + qTokenAddress: QI_AVAX, + qTokenSymbol: "qiAVAX", + underlyingAddress: undefined, + underlyingSymbol: "AVAX", + isNative: true, + }; + } + + if (normalized === QI_USDCE) { + return { + qTokenAddress: QI_USDCE, + qTokenSymbol: "qiUSDC", + underlyingAddress: USDC_E, + underlyingSymbol: "USDC.e", + isNative: false, + }; + } + + return undefined; + }), + }) +); + +vi.mock( + "../../../shared/services/transaction-evidence.service", + () => ({ + createEvidenceCorrelation: mockCreateEvidenceCorrelation, + persistEvidenceIntent: mockPersistEvidenceIntent, + persistPreparedEvidence: mockPersistPreparedEvidence, + }) +); + +import { + executePrepareBorrow, +} from "../../../modules/avax-lending/usecases/prepare-borrow.usecase"; + +import { + BENQI_SELECTORS, + PANORAMA_EXECUTOR_ABI_EXECUTE, +} from "../../../shared/bundle-builder"; + +describe("executePrepareBorrow evidence-bound migration", () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockEstimateGas.mockResolvedValue(100_000n); + + mockCreateEvidenceCorrelation.mockReturnValue({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + enabled: true, + }); + + mockPersistEvidenceIntent.mockResolvedValue(undefined); + + mockPersistPreparedEvidence.mockImplementation( + async (_intent, bundle) => ({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + evidenceEnabled: true, + preparedPayloadHash: ethers.keccak256( + ethers.toUtf8Bytes(JSON.stringify(bundle)) + ), + }) + ); + }); + + it("preserves native AVAX borrow semantics through the evidence boundary", async () => { + const amount = "1000000000000000000"; + + const result = await executePrepareBorrow({ + userAddress: USER, + qTokenAddress: QI_AVAX, + amount, + }); + + const evidenceResult = result as any; + + expect(evidenceResult.correlationId) + .toBe(CORRELATION_ID); + + expect(evidenceResult.evidenceVersion) + .toBe("1.0"); + + expect(evidenceResult.evidenceEnabled) + .toBe(true); + + expect(evidenceResult.preparedPayloadHash) + .toMatch(/^0x[a-fA-F0-9]{64}$/); + + expect(result.bundle.totalSteps).toBe(1); + expect(result.bundle.steps).toHaveLength(1); + + const borrow = result.bundle.steps[0]; + + expect(borrow.to).toBe(EXECUTOR); + expect(borrow.value).toBe("0"); + expect(borrow.chainId).toBe(43114); + + const executorInterface = + new ethers.Interface( + PANORAMA_EXECUTOR_ABI_EXECUTE + ); + + const decoded = + executorInterface.decodeFunctionData( + "execute", + borrow.data + ); + + expect(decoded[0]).toBe( + ethers.keccak256( + ethers.toUtf8Bytes("benqi") + ) + ); + + expect(decoded[1]).toBe( + BENQI_SELECTORS.BORROW_AVAX + ); + + expect(decoded[2]).toHaveLength(0); + + const adapterDecoded = + ethers.AbiCoder.defaultAbiCoder().decode( + ["uint256", "address"], + decoded[4] + ); + + expect(adapterDecoded[0]).toBe(BigInt(amount)); + expect(adapterDecoded[1]).toBe(USER); + + expect(result.metadata).toEqual({ + action: "borrow", + qTokenAddress: QI_AVAX, + qTokenSymbol: "qiAVAX", + underlyingSymbol: "AVAX", + amount, + isNative: true, + }); + + expect(mockPersistEvidenceIntent) + .toHaveBeenCalledTimes(1); + + expect(mockPersistPreparedEvidence) + .toHaveBeenCalledTimes(1); + + expect( + mockPersistPreparedEvidence.mock.calls[0][1] + ).toBe(result.bundle); + }); + + it("preserves ERC-20 borrow semantics and evidence ordering", async () => { + const amount = "1000000"; + + const result = await executePrepareBorrow({ + userAddress: USER, + qTokenAddress: QI_USDCE, + amount, + }); + + const evidenceResult = result as any; + + expect(evidenceResult.correlationId) + .toBe(CORRELATION_ID); + + expect(evidenceResult.evidenceEnabled) + .toBe(true); + + expect(result.bundle.totalSteps).toBe(1); + expect(result.bundle.steps).toHaveLength(1); + + const borrow = result.bundle.steps[0]; + + expect(borrow.to).toBe(EXECUTOR); + expect(borrow.value).toBe("0"); + expect(borrow.chainId).toBe(43114); + + const executorInterface = + new ethers.Interface( + PANORAMA_EXECUTOR_ABI_EXECUTE + ); + + const decoded = + executorInterface.decodeFunctionData( + "execute", + borrow.data + ); + + expect(decoded[1]).toBe( + BENQI_SELECTORS.BORROW + ); + + expect(decoded[2]).toHaveLength(0); + + const adapterDecoded = + ethers.AbiCoder.defaultAbiCoder().decode( + ["address", "uint256", "address"], + decoded[4] + ); + + expect( + adapterDecoded[0].toLowerCase() + ).toBe(QI_USDCE); + + expect(adapterDecoded[1]).toBe(BigInt(amount)); + expect(adapterDecoded[2]).toBe(USER); + + expect(result.metadata).toEqual({ + action: "borrow", + qTokenAddress: QI_USDCE, + qTokenSymbol: "qiUSDC", + underlyingSymbol: "USDC.e", + amount, + isNative: false, + }); + + // T1 intent persistence must precede T2 bundle preparation. + expect( + mockPersistEvidenceIntent.mock.invocationCallOrder[0] + ).toBeLessThan( + mockEstimateGas.mock.invocationCallOrder[0] + ); + + // T3 commitment persistence must follow preparation. + expect( + mockEstimateGas.mock.invocationCallOrder[0] + ).toBeLessThan( + mockPersistPreparedEvidence.mock.invocationCallOrder[0] + ); + }); +}); diff --git a/backend/src/modules/avax-lending/usecases/prepare-borrow.usecase.ts b/backend/src/modules/avax-lending/usecases/prepare-borrow.usecase.ts index 9e830c9..620c102 100644 --- a/backend/src/modules/avax-lending/usecases/prepare-borrow.usecase.ts +++ b/backend/src/modules/avax-lending/usecases/prepare-borrow.usecase.ts @@ -5,6 +5,9 @@ import { BundleBuilder, BENQI_SELECTORS } from "../../../shared/bundle-builder"; import { TransactionBundle } from "../../../types/transaction"; import { AppError } from "../../../shared/errorCodes"; import { getMarketByQToken } from "../config/avax-lending-markets"; +import { + prepareEvidenceBoundBundle, +} from "../../../shared/services/evidence-bound-preparation.service"; export interface PrepareBorrowRequest { userAddress: string; @@ -13,6 +16,10 @@ export interface PrepareBorrowRequest { } export interface PrepareBorrowResponse { + correlationId: string; + evidenceVersion: string; + evidenceEnabled: boolean; + preparedPayloadHash: string; bundle: TransactionBundle; metadata: { action: "borrow"; @@ -35,56 +42,75 @@ export async function executePrepareBorrow(req: PrepareBorrowRequest): Promise

{ + const protocolId = encodeProtocolId("benqi"); + const builder = new BundleBuilder(chain.chainId); + const deadline = getDeadline(20); + + // Borrow requires no token transfer from user. + // The adapter borrows and sends the asset to the recipient. + if (market.isNative) { + const adapterData = + ethers.AbiCoder.defaultAbiCoder().encode( + ["uint256", "address"], + [amount, req.userAddress] + ); - // Borrow requires no token transfer from user — the adapter borrows and sends to recipient - if (market.isNative) { - // borrowAVAX(uint256 amount, address recipient) - const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( - ["uint256", "address"], - [amount, req.userAddress] - ); + builder.addExecute( + protocolId, + BENQI_SELECTORS.BORROW_AVAX, + [], + deadline, + adapterData, + 0n, + executorAddr, + `Borrow ${ethers.formatEther(amount)} AVAX from Benqi` + ); + } else { + const adapterData = + ethers.AbiCoder.defaultAbiCoder().encode( + ["address", "uint256", "address"], + [req.qTokenAddress, amount, req.userAddress] + ); - builder.addExecute( - protocolId, - BENQI_SELECTORS.BORROW_AVAX, - [], - deadline, - adapterData, - 0n, - executorAddr, - `Borrow ${ethers.formatEther(amount)} AVAX from Benqi` - ); - } else { - // borrow(address qToken, uint256 amount, address recipient) - const adapterData = ethers.AbiCoder.defaultAbiCoder().encode( - ["address", "uint256", "address"], - [req.qTokenAddress, amount, req.userAddress] - ); + builder.addExecute( + protocolId, + BENQI_SELECTORS.BORROW, + [], + deadline, + adapterData, + 0n, + executorAddr, + `Borrow ${market.underlyingSymbol} from Benqi` + ); + } - builder.addExecute( - protocolId, - BENQI_SELECTORS.BORROW, - [], - deadline, - adapterData, - 0n, - executorAddr, - `Borrow ${market.underlyingSymbol} from Benqi` - ); - } + const bundle = await builder.buildWithGas( + `Borrow ${market.underlyingSymbol} from Benqi on Avalanche`, + req.userAddress + ); - return { - bundle: await builder.buildWithGas(`Borrow ${market.underlyingSymbol} from Benqi on Avalanche`, req.userAddress), - metadata: { - action: "borrow", - qTokenAddress: req.qTokenAddress, - qTokenSymbol: market.qTokenSymbol, - underlyingSymbol: market.underlyingSymbol, - amount: amount.toString(), - isNative: market.isNative, + return { + bundle, + metadata: { + action: "borrow" as const, + qTokenAddress: req.qTokenAddress, + qTokenSymbol: market.qTokenSymbol, + underlyingSymbol: market.underlyingSymbol, + amount: amount.toString(), + isNative: market.isNative, + }, + }; }, - }; + }); } From 35af30b774712a1129494da5bf4b30cb9db83604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= Date: Sun, 30 Aug 2026 21:34:07 +0200 Subject: [PATCH 07/18] feat: evidence-bind Benqi repay --- .../prepare-repay-evidence-bound.test.ts | 408 ++++++++++++++++++ .../usecases/prepare-repay.usecase.ts | 139 +++--- 2 files changed, 495 insertions(+), 52 deletions(-) create mode 100644 backend/src/__tests__/modules/avax-lending/prepare-repay-evidence-bound.test.ts diff --git a/backend/src/__tests__/modules/avax-lending/prepare-repay-evidence-bound.test.ts b/backend/src/__tests__/modules/avax-lending/prepare-repay-evidence-bound.test.ts new file mode 100644 index 0000000..75b0b96 --- /dev/null +++ b/backend/src/__tests__/modules/avax-lending/prepare-repay-evidence-bound.test.ts @@ -0,0 +1,408 @@ +import { + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { ethers } from "ethers"; + +const EXECUTOR = + "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12"; + +const USER = + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +const QI_AVAX = + "0x5c0401e81bc07ca70fad469b451682c0d747ef1c"; + +const QI_USDCE = + "0xbeb5d47a3f720ec0a390d04b4d41ed7d9688bc7f"; + +const USDC_E = + "0xa7d7079b0fead91f3e65f86e8915cb59c1a4c664"; + +const CORRELATION_ID = + "eeeeeeee-ffff-4aaa-8bbb-cccccccccccc"; + +const { + mockEstimateGas, + mockCheckAllowance, + mockCreateEvidenceCorrelation, + mockPersistEvidenceIntent, + mockPersistPreparedEvidence, +} = vi.hoisted(() => ({ + mockEstimateGas: vi.fn(), + mockCheckAllowance: vi.fn(), + mockCreateEvidenceCorrelation: vi.fn(), + mockPersistEvidenceIntent: vi.fn(), + mockPersistPreparedEvidence: vi.fn(), +})); + +vi.mock("../../../config/chains", () => ({ + getChainConfig: vi.fn(() => ({ + chainId: 43114, + name: "Avalanche", + contracts: { + panoramaExecutor: + "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12", + }, + })), +})); + +vi.mock("../../../providers/chain.provider", () => ({ + getProvider: vi.fn(() => ({ + estimateGas: mockEstimateGas, + })), +})); + +vi.mock( + "../../../shared/services/avax.service", + () => ({ + avaxService: { + checkAllowance: mockCheckAllowance, + }, + }) +); + +vi.mock( + "../../../modules/avax-lending/config/avax-lending-markets", + () => ({ + getMarketByQToken: vi.fn((address: string) => { + const normalized = address.toLowerCase(); + + if (normalized === QI_AVAX) { + return { + qTokenAddress: QI_AVAX, + qTokenSymbol: "qiAVAX", + underlyingAddress: undefined, + underlyingSymbol: "AVAX", + isNative: true, + }; + } + + if (normalized === QI_USDCE) { + return { + qTokenAddress: QI_USDCE, + qTokenSymbol: "qiUSDC", + underlyingAddress: USDC_E, + underlyingSymbol: "USDC.e", + isNative: false, + }; + } + + return undefined; + }), + }) +); + +vi.mock( + "../../../shared/services/transaction-evidence.service", + () => ({ + createEvidenceCorrelation: + mockCreateEvidenceCorrelation, + persistEvidenceIntent: + mockPersistEvidenceIntent, + persistPreparedEvidence: + mockPersistPreparedEvidence, + }) +); + +import { + executePrepareRepay, +} from "../../../modules/avax-lending/usecases/prepare-repay.usecase"; + +import { + BENQI_SELECTORS, + PANORAMA_EXECUTOR_ABI_EXECUTE, +} from "../../../shared/bundle-builder"; + +describe( + "executePrepareRepay evidence-bound migration", + () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockEstimateGas.mockResolvedValue(100_000n); + mockCheckAllowance.mockResolvedValue(0n); + + mockCreateEvidenceCorrelation.mockReturnValue({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + enabled: true, + }); + + mockPersistEvidenceIntent.mockResolvedValue( + undefined + ); + + mockPersistPreparedEvidence.mockImplementation( + async (_intent, bundle) => ({ + correlationId: CORRELATION_ID, + evidenceVersion: "1.0", + evidenceEnabled: true, + preparedPayloadHash: ethers.keccak256( + ethers.toUtf8Bytes( + JSON.stringify(bundle) + ) + ), + }) + ); + }); + + it( + "preserves native AVAX repay semantics through the evidence boundary", + async () => { + const amount = + "1000000000000000000"; + + const result = + await executePrepareRepay({ + userAddress: USER, + qTokenAddress: QI_AVAX, + amount, + }); + + const evidenceResult = + result as any; + + expect( + evidenceResult.correlationId + ).toBe(CORRELATION_ID); + + expect( + evidenceResult.evidenceVersion + ).toBe("1.0"); + + expect( + evidenceResult.evidenceEnabled + ).toBe(true); + + expect( + evidenceResult.preparedPayloadHash + ).toMatch( + /^0x[a-fA-F0-9]{64}$/ + ); + + expect( + result.bundle.totalSteps + ).toBe(1); + + expect( + result.bundle.steps + ).toHaveLength(1); + + const repay = + result.bundle.steps[0]; + + expect(repay.to) + .toBe(EXECUTOR); + + expect(repay.value) + .toBe(amount); + + expect(repay.chainId) + .toBe(43114); + + const executorInterface = + new ethers.Interface( + PANORAMA_EXECUTOR_ABI_EXECUTE + ); + + const decoded = + executorInterface + .decodeFunctionData( + "execute", + repay.data + ); + + expect(decoded[0]).toBe( + ethers.keccak256( + ethers.toUtf8Bytes( + "benqi" + ) + ) + ); + + expect(decoded[1]).toBe( + BENQI_SELECTORS.REPAY_AVAX + ); + + expect(decoded[2]) + .toHaveLength(0); + + expect(decoded[4]) + .toBe("0x"); + + expect(result.metadata).toEqual({ + action: "repay", + qTokenAddress: QI_AVAX, + qTokenSymbol: "qiAVAX", + underlyingSymbol: "AVAX", + amount, + isNative: true, + }); + + expect( + mockCheckAllowance + ).not.toHaveBeenCalled(); + + expect( + mockPersistEvidenceIntent + ).toHaveBeenCalledTimes(1); + + expect( + mockPersistPreparedEvidence + ).toHaveBeenCalledTimes(1); + } + ); + + it( + "preserves ERC-20 approval then repay semantics and evidence ordering", + async () => { + const amount = "1000000"; + + const result = + await executePrepareRepay({ + userAddress: USER, + qTokenAddress: QI_USDCE, + amount, + }); + + const evidenceResult = + result as any; + + expect( + evidenceResult.correlationId + ).toBe(CORRELATION_ID); + + expect( + evidenceResult.evidenceEnabled + ).toBe(true); + + expect( + mockCheckAllowance + ).toHaveBeenCalledWith( + USDC_E, + USER, + EXECUTOR, + BigInt(amount) + ); + + expect( + result.bundle.totalSteps + ).toBe(2); + + expect( + result.bundle.steps + ).toHaveLength(2); + + const approval = + result.bundle.steps[0]; + + const repay = + result.bundle.steps[1]; + + expect( + approval.to.toLowerCase() + ).toBe(USDC_E); + + expect(approval.value) + .toBe("0"); + + expect(approval.chainId) + .toBe(43114); + + expect(repay.to) + .toBe(EXECUTOR); + + expect(repay.value) + .toBe("0"); + + expect(repay.chainId) + .toBe(43114); + + const executorInterface = + new ethers.Interface( + PANORAMA_EXECUTOR_ABI_EXECUTE + ); + + const decoded = + executorInterface + .decodeFunctionData( + "execute", + repay.data + ); + + expect(decoded[1]).toBe( + BENQI_SELECTORS.REPAY + ); + + expect(decoded[2]) + .toHaveLength(1); + + expect( + decoded[2][0][0] + .toLowerCase() + ).toBe(USDC_E); + + expect( + decoded[2][0][1] + ).toBe(BigInt(amount)); + + const adapterDecoded = + ethers.AbiCoder + .defaultAbiCoder() + .decode( + [ + "address", + "uint256", + ], + decoded[4] + ); + + expect( + adapterDecoded[0] + .toLowerCase() + ).toBe(QI_USDCE); + + expect( + adapterDecoded[1] + ).toBe(BigInt(amount)); + + expect(result.metadata).toEqual({ + action: "repay", + qTokenAddress: QI_USDCE, + qTokenSymbol: "qiUSDC", + underlyingSymbol: "USDC.e", + amount, + isNative: false, + }); + + // T1 intent must precede + // T2 protocol preparation. + expect( + mockPersistEvidenceIntent + .mock + .invocationCallOrder[0] + ).toBeLessThan( + mockCheckAllowance + .mock + .invocationCallOrder[0] + ); + + // T2 preparation must precede + // T3 prepared commitment. + expect( + mockCheckAllowance + .mock + .invocationCallOrder[0] + ).toBeLessThan( + mockPersistPreparedEvidence + .mock + .invocationCallOrder[0] + ); + } + ); + } +); diff --git a/backend/src/modules/avax-lending/usecases/prepare-repay.usecase.ts b/backend/src/modules/avax-lending/usecases/prepare-repay.usecase.ts index 20304cd..b9769e2 100644 --- a/backend/src/modules/avax-lending/usecases/prepare-repay.usecase.ts +++ b/backend/src/modules/avax-lending/usecases/prepare-repay.usecase.ts @@ -6,6 +6,9 @@ import { BundleBuilder, BENQI_SELECTORS } from "../../../shared/bundle-builder"; import { TransactionBundle } from "../../../types/transaction"; import { AppError } from "../../../shared/errorCodes"; import { getMarketByQToken } from "../config/avax-lending-markets"; +import { + prepareEvidenceBoundBundle, +} from "../../../shared/services/evidence-bound-preparation.service"; export interface PrepareRepayRequest { userAddress: string; @@ -14,6 +17,10 @@ export interface PrepareRepayRequest { } export interface PrepareRepayResponse { + correlationId: string; + evidenceVersion: string; + evidenceEnabled: boolean; + preparedPayloadHash: string; bundle: TransactionBundle; metadata: { action: "repay"; @@ -36,62 +43,90 @@ export async function executePrepareRepay(req: PrepareRepayRequest): Promise

 {
+      const protocolId = encodeProtocolId("benqi");
+      const builder = new BundleBuilder(chain.chainId);
+      const deadline = getDeadline(20);
+
+      if (market.isNative) {
+        // repayAVAX() — native AVAX sent as msg.value, no params.
+        builder.addExecute(
+          protocolId,
+          BENQI_SELECTORS.REPAY_AVAX,
+          [],
+          deadline,
+          "0x",
+          amount,
+          executorAddr,
+          `Repay ${ethers.formatEther(amount)} AVAX to Benqi`
+        );
+      } else {
+        // The allowance read is deliberately inside the preparation
+        // boundary so T1 intent persistence precedes protocol state reads.
+        const allowance = await avaxService.checkAllowance(
+          market.underlyingAddress!,
+          req.userAddress,
+          executorAddr,
+          amount
+        );
 
-  if (market.isNative) {
-    // repayAVAX() — native AVAX sent as msg.value, no params
-    const adapterData = "0x";
+        builder.addApproveIfNeeded(
+          market.underlyingAddress!,
+          executorAddr,
+          allowance,
+          amount,
+          `Approve ${market.underlyingSymbol} for PanoramaExecutor repay`
+        );
 
-    builder.addExecute(
-      protocolId,
-      BENQI_SELECTORS.REPAY_AVAX,
-      [],
-      deadline,
-      adapterData,
-      amount,
-      executorAddr,
-      `Repay ${ethers.formatEther(amount)} AVAX to Benqi`
-    );
-  } else {
-    // approve underlying → executor
-    const allowance = await avaxService.checkAllowance(market.underlyingAddress!, req.userAddress, executorAddr, amount);
-    builder.addApproveIfNeeded(
-      market.underlyingAddress!,
-      executorAddr,
-      allowance,
-      amount,
-      `Approve ${market.underlyingSymbol} for PanoramaExecutor repay`
-    );
+        const adapterData =
+          ethers.AbiCoder.defaultAbiCoder().encode(
+            ["address", "uint256"],
+            [req.qTokenAddress, amount]
+          );
 
-    // repay(address qToken, uint256 amount)
-    const adapterData = ethers.AbiCoder.defaultAbiCoder().encode(
-      ["address", "uint256"],
-      [req.qTokenAddress, amount]
-    );
+        builder.addExecute(
+          protocolId,
+          BENQI_SELECTORS.REPAY,
+          [
+            {
+              token: market.underlyingAddress!,
+              amount,
+            },
+          ],
+          deadline,
+          adapterData,
+          0n,
+          executorAddr,
+          `Repay ${market.underlyingSymbol} to Benqi`
+        );
+      }
 
-    builder.addExecute(
-      protocolId,
-      BENQI_SELECTORS.REPAY,
-      [{ token: market.underlyingAddress!, amount }],
-      deadline,
-      adapterData,
-      0n,
-      executorAddr,
-      `Repay ${market.underlyingSymbol} to Benqi`
-    );
-  }
+      const bundle = await builder.buildWithGas(
+        `Repay ${market.underlyingSymbol} to Benqi on Avalanche`,
+        req.userAddress
+      );
 
-  return {
-    bundle: await builder.buildWithGas(`Repay ${market.underlyingSymbol} to Benqi on Avalanche`, req.userAddress),
-    metadata: {
-      action:           "repay",
-      qTokenAddress:    req.qTokenAddress,
-      qTokenSymbol:     market.qTokenSymbol,
-      underlyingSymbol: market.underlyingSymbol,
-      amount:           amount.toString(),
-      isNative:         market.isNative,
+      return {
+        bundle,
+        metadata: {
+          action: "repay" as const,
+          qTokenAddress: req.qTokenAddress,
+          qTokenSymbol: market.qTokenSymbol,
+          underlyingSymbol: market.underlyingSymbol,
+          amount: amount.toString(),
+          isNative: market.isNative,
+        },
+      };
     },
-  };
+  });
 }

From b6661350aa952112256bdc1c0b354ae52943e1cf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= 
Date: Sun, 30 Aug 2026 21:34:07 +0200
Subject: [PATCH 08/18] test: fix bundle builder vitest import

---
 backend/src/__tests__/shared/bundle-builder.test.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/backend/src/__tests__/shared/bundle-builder.test.ts b/backend/src/__tests__/shared/bundle-builder.test.ts
index ca60a3b..6207d0a 100644
--- a/backend/src/__tests__/shared/bundle-builder.test.ts
+++ b/backend/src/__tests__/shared/bundle-builder.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach } from "vitest";
 import { ethers } from "ethers";
 
 const { mockEstimateGas } = vi.hoisted(() => ({

From a10ed5e0144f2af386edb7b96931a59bacf6b3bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= 
Date: Sun, 30 Aug 2026 22:04:06 +0200
Subject: [PATCH 09/18] feat: evidence-bind sAVAX stake

---
 .../prepare-stake-evidence-bound.test.ts      | 229 ++++++++++++++++++
 .../usecases/prepare-stake.usecase.ts         | 105 +++++---
 2 files changed, 302 insertions(+), 32 deletions(-)
 create mode 100644 backend/src/__tests__/modules/avax-liquid-staking/prepare-stake-evidence-bound.test.ts

diff --git a/backend/src/__tests__/modules/avax-liquid-staking/prepare-stake-evidence-bound.test.ts b/backend/src/__tests__/modules/avax-liquid-staking/prepare-stake-evidence-bound.test.ts
new file mode 100644
index 0000000..bd9510b
--- /dev/null
+++ b/backend/src/__tests__/modules/avax-liquid-staking/prepare-stake-evidence-bound.test.ts
@@ -0,0 +1,229 @@
+import {
+  beforeEach,
+  describe,
+  expect,
+  it,
+  vi,
+} from "vitest";
+import { ethers } from "ethers";
+
+const EXECUTOR =
+  "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12";
+
+const USER =
+  "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
+
+const CORRELATION_ID =
+  "66666666-aaaa-4bbb-8ccc-dddddddddddd";
+
+const {
+  mockEstimateGas,
+  mockCreateEvidenceCorrelation,
+  mockPersistEvidenceIntent,
+  mockPersistPreparedEvidence,
+} = vi.hoisted(() => ({
+  mockEstimateGas: vi.fn(),
+  mockCreateEvidenceCorrelation: vi.fn(),
+  mockPersistEvidenceIntent: vi.fn(),
+  mockPersistPreparedEvidence: vi.fn(),
+}));
+
+vi.mock("../../../config/chains", () => ({
+  getChainConfig: vi.fn(() => ({
+    chainId: 43114,
+    name: "Avalanche",
+    contracts: {
+      panoramaExecutor:
+        "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12",
+    },
+  })),
+}));
+
+vi.mock(
+  "../../../providers/chain.provider",
+  () => ({
+    getProvider: vi.fn(() => ({
+      estimateGas: mockEstimateGas,
+    })),
+  })
+);
+
+vi.mock(
+  "../../../shared/services/transaction-evidence.service",
+  () => ({
+    createEvidenceCorrelation:
+      mockCreateEvidenceCorrelation,
+    persistEvidenceIntent:
+      mockPersistEvidenceIntent,
+    persistPreparedEvidence:
+      mockPersistPreparedEvidence,
+  })
+);
+
+import {
+  executePrepareStake,
+} from "../../../modules/avax-liquid-staking/usecases/prepare-stake.usecase";
+
+import {
+  PANORAMA_EXECUTOR_ABI_EXECUTE,
+  SAVAX_SELECTORS,
+} from "../../../shared/bundle-builder";
+
+describe(
+  "executePrepareStake evidence-bound migration",
+  () => {
+    beforeEach(() => {
+      vi.clearAllMocks();
+
+      mockEstimateGas.mockResolvedValue(100_000n);
+
+      mockCreateEvidenceCorrelation.mockReturnValue({
+        correlationId: CORRELATION_ID,
+        evidenceVersion: "1.0",
+        enabled: true,
+      });
+
+      mockPersistEvidenceIntent.mockResolvedValue(
+        undefined
+      );
+
+      mockPersistPreparedEvidence.mockImplementation(
+        async (_intent, bundle) => ({
+          correlationId: CORRELATION_ID,
+          evidenceVersion: "1.0",
+          evidenceEnabled: true,
+          preparedPayloadHash: ethers.keccak256(
+            ethers.toUtf8Bytes(
+              JSON.stringify(bundle)
+            )
+          ),
+        })
+      );
+    });
+
+    it(
+      "preserves native AVAX to sAVAX stake semantics through the evidence boundary",
+      async () => {
+        const amount =
+          "100000000000000000";
+
+        const result =
+          await executePrepareStake({
+            userAddress: USER,
+            amount,
+          });
+
+        const evidenceResult =
+          result as any;
+
+        expect(
+          evidenceResult.correlationId
+        ).toBe(CORRELATION_ID);
+
+        expect(
+          evidenceResult.evidenceVersion
+        ).toBe("1.0");
+
+        expect(
+          evidenceResult.evidenceEnabled
+        ).toBe(true);
+
+        expect(
+          evidenceResult.preparedPayloadHash
+        ).toMatch(
+          /^0x[a-fA-F0-9]{64}$/
+        );
+
+        expect(
+          result.bundle.totalSteps
+        ).toBe(1);
+
+        expect(
+          result.bundle.steps
+        ).toHaveLength(1);
+
+        const stake =
+          result.bundle.steps[0];
+
+        expect(stake.to)
+          .toBe(EXECUTOR);
+
+        expect(stake.value)
+          .toBe(amount);
+
+        expect(stake.chainId)
+          .toBe(43114);
+
+        const executorInterface =
+          new ethers.Interface(
+            PANORAMA_EXECUTOR_ABI_EXECUTE
+          );
+
+        const decoded =
+          executorInterface.decodeFunctionData(
+            "execute",
+            stake.data
+          );
+
+        expect(decoded[0]).toBe(
+          ethers.keccak256(
+            ethers.toUtf8Bytes("savax")
+          )
+        );
+
+        expect(decoded[1]).toBe(
+          SAVAX_SELECTORS.STAKE
+        );
+
+        expect(decoded[2])
+          .toHaveLength(0);
+
+        const adapterDecoded =
+          ethers.AbiCoder
+            .defaultAbiCoder()
+            .decode(
+              ["address"],
+              decoded[4]
+            );
+
+        expect(
+          adapterDecoded[0]
+            .toLowerCase()
+        ).toBe(
+          USER.toLowerCase()
+        );
+
+        expect(result.metadata)
+          .toEqual({
+            action: "stake",
+            avaxAmount: amount,
+            estimatedSAvax: amount,
+          });
+
+        expect(
+          mockPersistEvidenceIntent
+        ).toHaveBeenCalledTimes(1);
+
+        expect(
+          mockPersistPreparedEvidence
+        ).toHaveBeenCalledTimes(1);
+
+        expect(
+          mockPersistEvidenceIntent
+            .mock.invocationCallOrder[0]
+        ).toBeLessThan(
+          mockEstimateGas
+            .mock.invocationCallOrder[0]
+        );
+
+        expect(
+          mockEstimateGas
+            .mock.invocationCallOrder[0]
+        ).toBeLessThan(
+          mockPersistPreparedEvidence
+            .mock.invocationCallOrder[0]
+        );
+      }
+    );
+  }
+);
diff --git a/backend/src/modules/avax-liquid-staking/usecases/prepare-stake.usecase.ts b/backend/src/modules/avax-liquid-staking/usecases/prepare-stake.usecase.ts
index 3447345..e50bea2 100644
--- a/backend/src/modules/avax-liquid-staking/usecases/prepare-stake.usecase.ts
+++ b/backend/src/modules/avax-liquid-staking/usecases/prepare-stake.usecase.ts
@@ -4,6 +4,9 @@ import { encodeProtocolId, getDeadline } from "../../../utils/encoding";
 import { BundleBuilder, SAVAX_SELECTORS } from "../../../shared/bundle-builder";
 import { TransactionBundle } from "../../../types/transaction";
 import { AppError } from "../../../shared/errorCodes";
+import {
+  prepareEvidenceBoundBundle,
+} from "../../../shared/services/evidence-bound-preparation.service";
 
 export interface PrepareStakeRequest {
   userAddress: string;
@@ -11,6 +14,10 @@ export interface PrepareStakeRequest {
 }
 
 export interface PrepareStakeResponse {
+  correlationId: string;
+  evidenceVersion: string;
+  evidenceEnabled: boolean;
+  preparedPayloadHash: string;
   bundle: TransactionBundle;
   metadata: {
     action: "stake";
@@ -19,41 +26,75 @@ export interface PrepareStakeResponse {
   };
 }
 
-export async function executePrepareStake(req: PrepareStakeRequest): Promise {
-  const chain        = getChainConfig("avalanche");
+export async function executePrepareStake(
+  req: PrepareStakeRequest
+): Promise {
+  const chain = getChainConfig("avalanche");
   const executorAddr = chain.contracts.panoramaExecutor;
-  if (!executorAddr) throw new AppError("INTERNAL_ERROR", "PanoramaExecutor not deployed on Avalanche");
+
+  if (!executorAddr) {
+    throw new AppError(
+      "INTERNAL_ERROR",
+      "PanoramaExecutor not deployed on Avalanche"
+    );
+  }
 
   const amount = BigInt(req.amount);
-  if (amount <= 0n) throw new AppError("INVALID_AMOUNT", "amount must be positive");
-
-  const protocolId = encodeProtocolId("savax");
-  const builder    = new BundleBuilder(chain.chainId);
-  const deadline   = getDeadline(20);
-
-  // stake(address recipient) — native AVAX sent as msg.value
-  const adapterData = ethers.AbiCoder.defaultAbiCoder().encode(
-    ["address"],
-    [req.userAddress]
-  );
-
-  builder.addExecute(
-    protocolId,
-    SAVAX_SELECTORS.STAKE,
-    [],
-    deadline,
-    adapterData,
-    amount,
-    executorAddr,
-    `Stake ${ethers.formatEther(amount)} AVAX → receive sAVAX via BENQI`
-  );
-
-  return {
-    bundle: await builder.buildWithGas(`Stake ${ethers.formatEther(amount)} AVAX for sAVAX`, req.userAddress),
-    metadata: {
+
+  if (amount <= 0n) {
+    throw new AppError(
+      "INVALID_AMOUNT",
+      "amount must be positive"
+    );
+  }
+
+  return prepareEvidenceBoundBundle({
+    intent: {
       action: "stake",
-      avaxAmount: amount.toString(),
-      estimatedSAvax: amount.toString(), // 1:1 approximation — actual rate varies
+      chainId: chain.chainId,
+      network: "avalanche-c-chain",
+      walletAddress: req.userAddress,
+      assetIn: "AVAX",
+      assetOut: "0x2b2C81e08f1Af8835a78Bb2A90AE924ACE0eA4bE",
+      amountRaw: amount.toString(),
     },
-  };
+
+    prepare: async () => {
+      const protocolId = encodeProtocolId("savax");
+      const builder = new BundleBuilder(chain.chainId);
+      const deadline = getDeadline(20);
+
+      const adapterData = ethers.AbiCoder
+        .defaultAbiCoder()
+        .encode(
+          ["address"],
+          [req.userAddress]
+        );
+
+      builder.addExecute(
+        protocolId,
+        SAVAX_SELECTORS.STAKE,
+        [],
+        deadline,
+        adapterData,
+        amount,
+        executorAddr,
+        `Stake ${ethers.formatEther(amount)} AVAX → receive sAVAX via BENQI`
+      );
+
+      const bundle = await builder.buildWithGas(
+        `Stake ${ethers.formatEther(amount)} AVAX for sAVAX`,
+        req.userAddress
+      );
+
+      return {
+        bundle,
+        metadata: {
+          action: "stake" as const,
+          avaxAmount: amount.toString(),
+          estimatedSAvax: amount.toString(),
+        },
+      };
+    },
+  });
 }

From 448ba32ae5328e669d806698d22e55dd0350bb09 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= 
Date: Sun, 30 Aug 2026 22:21:05 +0200
Subject: [PATCH 10/18] feat: evidence-bind sAVAX unlock

---
 ...pare-request-unlock-evidence-bound.test.ts | 300 ++++++++++++++++++
 .../prepare-request-unlock.usecase.ts         | 161 +++++++---
 2 files changed, 413 insertions(+), 48 deletions(-)
 create mode 100644 backend/src/__tests__/modules/avax-liquid-staking/prepare-request-unlock-evidence-bound.test.ts

diff --git a/backend/src/__tests__/modules/avax-liquid-staking/prepare-request-unlock-evidence-bound.test.ts b/backend/src/__tests__/modules/avax-liquid-staking/prepare-request-unlock-evidence-bound.test.ts
new file mode 100644
index 0000000..74dc91c
--- /dev/null
+++ b/backend/src/__tests__/modules/avax-liquid-staking/prepare-request-unlock-evidence-bound.test.ts
@@ -0,0 +1,300 @@
+import {
+  beforeEach,
+  describe,
+  expect,
+  it,
+  vi,
+} from "vitest";
+import { ethers } from "ethers";
+
+const EXECUTOR =
+  "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12";
+
+const SAVAX =
+  "0x2b2C81e08f1Af8835a78Bb2A90AE924ACE0eA4bE";
+
+const USER =
+  "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
+
+const CORRELATION_ID =
+  "77777777-aaaa-4bbb-8ccc-dddddddddddd";
+
+const {
+  mockEstimateGas,
+  mockCheckAllowance,
+  mockCreateEvidenceCorrelation,
+  mockPersistEvidenceIntent,
+  mockPersistPreparedEvidence,
+} = vi.hoisted(() => ({
+  mockEstimateGas: vi.fn(),
+  mockCheckAllowance: vi.fn(),
+  mockCreateEvidenceCorrelation: vi.fn(),
+  mockPersistEvidenceIntent: vi.fn(),
+  mockPersistPreparedEvidence: vi.fn(),
+}));
+
+vi.mock("../../../config/chains", () => ({
+  getChainConfig: vi.fn(() => ({
+    chainId: 43114,
+    name: "Avalanche",
+    contracts: {
+      panoramaExecutor:
+        "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12",
+    },
+  })),
+}));
+
+vi.mock(
+  "../../../providers/chain.provider",
+  () => ({
+    getProvider: vi.fn(() => ({
+      estimateGas: mockEstimateGas,
+    })),
+  })
+);
+
+vi.mock(
+  "../../../shared/services/avax.service",
+  () => ({
+    avaxService: {
+      checkAllowance: mockCheckAllowance,
+    },
+  })
+);
+
+vi.mock(
+  "../../../shared/services/transaction-evidence.service",
+  () => ({
+    createEvidenceCorrelation:
+      mockCreateEvidenceCorrelation,
+    persistEvidenceIntent:
+      mockPersistEvidenceIntent,
+    persistPreparedEvidence:
+      mockPersistPreparedEvidence,
+  })
+);
+
+import {
+  executePrepareRequestUnlock,
+} from "../../../modules/avax-liquid-staking/usecases/prepare-request-unlock.usecase";
+
+import {
+  ERC20_APPROVE_ABI,
+  PANORAMA_EXECUTOR_ABI_EXECUTE,
+  SAVAX_SELECTORS,
+} from "../../../shared/bundle-builder";
+
+describe(
+  "executePrepareRequestUnlock evidence-bound migration",
+  () => {
+    beforeEach(() => {
+      vi.clearAllMocks();
+
+      mockEstimateGas.mockResolvedValue(100_000n);
+
+      mockCreateEvidenceCorrelation.mockReturnValue({
+        correlationId: CORRELATION_ID,
+        evidenceVersion: "1.0",
+        enabled: true,
+      });
+
+      mockPersistEvidenceIntent.mockResolvedValue(
+        undefined
+      );
+
+      mockPersistPreparedEvidence.mockImplementation(
+        async (_intent, bundle) => ({
+          correlationId: CORRELATION_ID,
+          evidenceVersion: "1.0",
+          evidenceEnabled: true,
+          preparedPayloadHash: ethers.keccak256(
+            ethers.toUtf8Bytes(
+              JSON.stringify(bundle)
+            )
+          ),
+        })
+      );
+    });
+
+    it(
+      "preserves approval then requestUnlock semantics through the evidence boundary",
+      async () => {
+        const amount =
+          "100000000000000000";
+
+        mockCheckAllowance.mockResolvedValue(0n);
+
+        const result =
+          await executePrepareRequestUnlock({
+            userAddress: USER,
+            sAvaxAmount: amount,
+          });
+
+        const evidenceResult = result as any;
+
+        expect(
+          evidenceResult.correlationId
+        ).toBe(CORRELATION_ID);
+
+        expect(
+          evidenceResult.evidenceVersion
+        ).toBe("1.0");
+
+        expect(
+          evidenceResult.evidenceEnabled
+        ).toBe(true);
+
+        expect(
+          evidenceResult.preparedPayloadHash
+        ).toMatch(
+          /^0x[a-fA-F0-9]{64}$/
+        );
+
+        expect(result.bundle.totalSteps).toBe(2);
+        expect(result.bundle.steps).toHaveLength(2);
+
+        const approval = result.bundle.steps[0];
+        const unlock = result.bundle.steps[1];
+
+        expect(
+          approval.to.toLowerCase()
+        ).toBe(SAVAX.toLowerCase());
+
+        expect(approval.value).toBe("0");
+        expect(approval.chainId).toBe(43114);
+
+        const approveInterface =
+          new ethers.Interface(
+            ERC20_APPROVE_ABI
+          );
+
+        const approveDecoded =
+          approveInterface.decodeFunctionData(
+            "approve",
+            approval.data
+          );
+
+        expect(
+          approveDecoded[0].toLowerCase()
+        ).toBe(EXECUTOR.toLowerCase());
+
+        expect(
+          approveDecoded[1].toString()
+        ).toBe(amount);
+
+        expect(unlock.to).toBe(EXECUTOR);
+        expect(unlock.value).toBe("0");
+        expect(unlock.chainId).toBe(43114);
+
+        const executorInterface =
+          new ethers.Interface(
+            PANORAMA_EXECUTOR_ABI_EXECUTE
+          );
+
+        const decoded =
+          executorInterface.decodeFunctionData(
+            "execute",
+            unlock.data
+          );
+
+        expect(decoded[0]).toBe(
+          ethers.keccak256(
+            ethers.toUtf8Bytes("savax")
+          )
+        );
+
+        expect(decoded[1]).toBe(
+          SAVAX_SELECTORS.REQUEST_UNLOCK
+        );
+
+        expect(decoded[2]).toHaveLength(1);
+
+        expect(
+          decoded[2][0].token.toLowerCase()
+        ).toBe(SAVAX.toLowerCase());
+
+        expect(
+          decoded[2][0].amount.toString()
+        ).toBe(amount);
+
+        const adapterDecoded =
+          ethers.AbiCoder
+            .defaultAbiCoder()
+            .decode(
+              ["uint256"],
+              decoded[4]
+            );
+
+        expect(
+          adapterDecoded[0].toString()
+        ).toBe(amount);
+
+        expect(result.metadata).toEqual({
+          action: "requestUnlock",
+          sAvaxAmount: amount,
+          estimatedAvax: amount,
+          cooldownDays: 15,
+        });
+
+        expect(
+          mockPersistEvidenceIntent
+        ).toHaveBeenCalledTimes(1);
+
+        expect(
+          mockPersistPreparedEvidence
+        ).toHaveBeenCalledTimes(1);
+
+        expect(
+          mockPersistEvidenceIntent
+            .mock.invocationCallOrder[0]
+        ).toBeLessThan(
+          mockCheckAllowance
+            .mock.invocationCallOrder[0]
+        );
+
+        expect(
+          mockCheckAllowance
+            .mock.invocationCallOrder[0]
+        ).toBeLessThan(
+          mockPersistPreparedEvidence
+            .mock.invocationCallOrder[0]
+        );
+      }
+    );
+
+    it(
+      "preserves single-step requestUnlock when approval is already sufficient",
+      async () => {
+        const amount =
+          "100000000000000000";
+
+        mockCheckAllowance.mockResolvedValue(
+          BigInt(amount)
+        );
+
+        const result =
+          await executePrepareRequestUnlock({
+            userAddress: USER,
+            sAvaxAmount: amount,
+          });
+
+        expect(result.bundle.totalSteps).toBe(1);
+        expect(result.bundle.steps).toHaveLength(1);
+
+        const unlock = result.bundle.steps[0];
+
+        expect(unlock.to).toBe(EXECUTOR);
+        expect(unlock.value).toBe("0");
+        expect(unlock.chainId).toBe(43114);
+
+        expect(
+          mockPersistEvidenceIntent
+        ).toHaveBeenCalledTimes(1);
+
+        expect(
+          mockPersistPreparedEvidence
+        ).toHaveBeenCalledTimes(1);
+      }
+    );
+  }
+);
diff --git a/backend/src/modules/avax-liquid-staking/usecases/prepare-request-unlock.usecase.ts b/backend/src/modules/avax-liquid-staking/usecases/prepare-request-unlock.usecase.ts
index 3e27d35..03d1072 100644
--- a/backend/src/modules/avax-liquid-staking/usecases/prepare-request-unlock.usecase.ts
+++ b/backend/src/modules/avax-liquid-staking/usecases/prepare-request-unlock.usecase.ts
@@ -5,6 +5,9 @@ import { encodeProtocolId, getDeadline } from "../../../utils/encoding";
 import { BundleBuilder, SAVAX_SELECTORS } from "../../../shared/bundle-builder";
 import { TransactionBundle } from "../../../types/transaction";
 import { AppError } from "../../../shared/errorCodes";
+import {
+  prepareEvidenceBoundBundle,
+} from "../../../shared/services/evidence-bound-preparation.service";
 
 export interface PrepareRequestUnlockRequest {
   userAddress: string;
@@ -12,6 +15,10 @@ export interface PrepareRequestUnlockRequest {
 }
 
 export interface PrepareRequestUnlockResponse {
+  correlationId: string;
+  evidenceVersion: string;
+  evidenceEnabled: boolean;
+  preparedPayloadHash: string;
   bundle: TransactionBundle;
   metadata: {
     action: "requestUnlock";
@@ -21,54 +28,112 @@ export interface PrepareRequestUnlockResponse {
   };
 }
 
-const S_AVAX_ADDRESS = "0x2b2C81e08f1Af8835a78Bb2A90AE924ACE0eA4bE";
-
-export async function executePrepareRequestUnlock(req: PrepareRequestUnlockRequest): Promise {
-  const chain        = getChainConfig("avalanche");
-  const executorAddr = chain.contracts.panoramaExecutor;
-  if (!executorAddr) throw new AppError("INTERNAL_ERROR", "PanoramaExecutor not deployed on Avalanche");
-
-  const sAvaxAmount = BigInt(req.sAvaxAmount);
-  if (sAvaxAmount <= 0n) throw new AppError("INVALID_AMOUNT", "sAvaxAmount must be positive");
-
-  const protocolId = encodeProtocolId("savax");
-  const builder    = new BundleBuilder(chain.chainId);
-  const deadline   = getDeadline(20);
-
-  // Approve sAVAX → executor so it can pull to the proxy
-  const allowance = await avaxService.checkAllowance(S_AVAX_ADDRESS, req.userAddress, executorAddr, sAvaxAmount);
-  builder.addApproveIfNeeded(
-    S_AVAX_ADDRESS,
-    executorAddr,
-    allowance,
-    sAvaxAmount,
-    "Approve sAVAX for PanoramaExecutor"
-  );
-
-  // requestUnlock(uint256 shareAmount)
-  const adapterData = ethers.AbiCoder.defaultAbiCoder().encode(
-    ["uint256"],
-    [sAvaxAmount]
-  );
-
-  builder.addExecute(
-    protocolId,
-    SAVAX_SELECTORS.REQUEST_UNLOCK,
-    [{ token: S_AVAX_ADDRESS, amount: sAvaxAmount }],
-    deadline,
-    adapterData,
-    0n,
-    executorAddr,
-    `Request unlock of ${ethers.formatEther(sAvaxAmount)} sAVAX (~15 day cooldown)`
-  );
-
-  return {
-    bundle: await builder.buildWithGas(`Request unlock ${ethers.formatEther(sAvaxAmount)} sAVAX`, req.userAddress),
-    metadata: {
+const S_AVAX_ADDRESS =
+  "0x2b2C81e08f1Af8835a78Bb2A90AE924ACE0eA4bE";
+
+export async function executePrepareRequestUnlock(
+  req: PrepareRequestUnlockRequest
+): Promise {
+  const chain = getChainConfig("avalanche");
+  const executorAddr =
+    chain.contracts.panoramaExecutor;
+
+  if (!executorAddr) {
+    throw new AppError(
+      "INTERNAL_ERROR",
+      "PanoramaExecutor not deployed on Avalanche"
+    );
+  }
+
+  const sAvaxAmount =
+    BigInt(req.sAvaxAmount);
+
+  if (sAvaxAmount <= 0n) {
+    throw new AppError(
+      "INVALID_AMOUNT",
+      "sAvaxAmount must be positive"
+    );
+  }
+
+  return prepareEvidenceBoundBundle({
+    intent: {
       action: "requestUnlock",
-      sAvaxAmount: sAvaxAmount.toString(),
-      estimatedAvax: sAvaxAmount.toString(), // approximation
-      cooldownDays: 15,
+      chainId: chain.chainId,
+      network: "avalanche-c-chain",
+      walletAddress: req.userAddress,
+      assetIn: S_AVAX_ADDRESS,
+      assetOut: "AVAX",
+      amountRaw: sAvaxAmount.toString(),
     },
-  };
+
+    prepare: async () => {
+      const protocolId =
+        encodeProtocolId("savax");
+
+      const builder =
+        new BundleBuilder(chain.chainId);
+
+      const deadline =
+        getDeadline(20);
+
+      const allowance =
+        await avaxService.checkAllowance(
+          S_AVAX_ADDRESS,
+          req.userAddress,
+          executorAddr,
+          sAvaxAmount
+        );
+
+      builder.addApproveIfNeeded(
+        S_AVAX_ADDRESS,
+        executorAddr,
+        allowance,
+        sAvaxAmount,
+        "Approve sAVAX for PanoramaExecutor"
+      );
+
+      const adapterData =
+        ethers.AbiCoder
+          .defaultAbiCoder()
+          .encode(
+            ["uint256"],
+            [sAvaxAmount]
+          );
+
+      builder.addExecute(
+        protocolId,
+        SAVAX_SELECTORS.REQUEST_UNLOCK,
+        [
+          {
+            token: S_AVAX_ADDRESS,
+            amount: sAvaxAmount,
+          },
+        ],
+        deadline,
+        adapterData,
+        0n,
+        executorAddr,
+        `Request unlock of ${ethers.formatEther(sAvaxAmount)} sAVAX (~15 day cooldown)`
+      );
+
+      const bundle =
+        await builder.buildWithGas(
+          `Request unlock ${ethers.formatEther(sAvaxAmount)} sAVAX`,
+          req.userAddress
+        );
+
+      return {
+        bundle,
+        metadata: {
+          action:
+            "requestUnlock" as const,
+          sAvaxAmount:
+            sAvaxAmount.toString(),
+          estimatedAvax:
+            sAvaxAmount.toString(),
+          cooldownDays: 15,
+        },
+      };
+    },
+  });
 }

From 4d27757c4bc0de5747bc5f77a3a970f6d93d36d7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= 
Date: Sun, 30 Aug 2026 22:29:15 +0200
Subject: [PATCH 11/18] feat: evidence-bind sAVAX redeem

---
 .../prepare-redeem-evidence-bound.test.ts     | 233 ++++++++++++++++++
 .../usecases/prepare-redeem.usecase.ts        | 119 ++++++---
 2 files changed, 319 insertions(+), 33 deletions(-)
 create mode 100644 backend/src/__tests__/modules/avax-liquid-staking/prepare-redeem-evidence-bound.test.ts

diff --git a/backend/src/__tests__/modules/avax-liquid-staking/prepare-redeem-evidence-bound.test.ts b/backend/src/__tests__/modules/avax-liquid-staking/prepare-redeem-evidence-bound.test.ts
new file mode 100644
index 0000000..ac12a52
--- /dev/null
+++ b/backend/src/__tests__/modules/avax-liquid-staking/prepare-redeem-evidence-bound.test.ts
@@ -0,0 +1,233 @@
+import {
+  beforeEach,
+  describe,
+  expect,
+  it,
+  vi,
+} from "vitest";
+import { ethers } from "ethers";
+
+const EXECUTOR =
+  "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12";
+
+const USER =
+  "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
+
+const CORRELATION_ID =
+  "88888888-aaaa-4bbb-8ccc-dddddddddddd";
+
+const {
+  mockEstimateGas,
+  mockCreateEvidenceCorrelation,
+  mockPersistEvidenceIntent,
+  mockPersistPreparedEvidence,
+} = vi.hoisted(() => ({
+  mockEstimateGas: vi.fn(),
+  mockCreateEvidenceCorrelation: vi.fn(),
+  mockPersistEvidenceIntent: vi.fn(),
+  mockPersistPreparedEvidence: vi.fn(),
+}));
+
+vi.mock("../../../config/chains", () => ({
+  getChainConfig: vi.fn(() => ({
+    chainId: 43114,
+    name: "Avalanche",
+    contracts: {
+      panoramaExecutor:
+        "0xc35059D1BC395Ff0F6fDcEA1b7F365E3aa7C1D12",
+    },
+  })),
+}));
+
+vi.mock(
+  "../../../providers/chain.provider",
+  () => ({
+    getProvider: vi.fn(() => ({
+      estimateGas: mockEstimateGas,
+    })),
+  })
+);
+
+vi.mock(
+  "../../../shared/services/transaction-evidence.service",
+  () => ({
+    createEvidenceCorrelation:
+      mockCreateEvidenceCorrelation,
+    persistEvidenceIntent:
+      mockPersistEvidenceIntent,
+    persistPreparedEvidence:
+      mockPersistPreparedEvidence,
+  })
+);
+
+import {
+  executePrepareRedeem,
+} from "../../../modules/avax-liquid-staking/usecases/prepare-redeem.usecase";
+
+import {
+  PANORAMA_EXECUTOR_ABI_EXECUTE,
+  SAVAX_SELECTORS,
+} from "../../../shared/bundle-builder";
+
+describe(
+  "executePrepareRedeem evidence-bound migration",
+  () => {
+    beforeEach(() => {
+      vi.clearAllMocks();
+
+      mockEstimateGas.mockResolvedValue(100_000n);
+
+      mockCreateEvidenceCorrelation.mockReturnValue({
+        correlationId: CORRELATION_ID,
+        evidenceVersion: "1.0",
+        enabled: true,
+      });
+
+      mockPersistEvidenceIntent.mockResolvedValue(
+        undefined
+      );
+
+      mockPersistPreparedEvidence.mockImplementation(
+        async (_intent, bundle) => ({
+          correlationId: CORRELATION_ID,
+          evidenceVersion: "1.0",
+          evidenceEnabled: true,
+          preparedPayloadHash: ethers.keccak256(
+            ethers.toUtf8Bytes(
+              JSON.stringify(bundle)
+            )
+          ),
+        })
+      );
+    });
+
+    it(
+      "preserves sAVAX redeem semantics through the evidence boundary",
+      async () => {
+        const userUnlockIndex = 3;
+
+        const result =
+          await executePrepareRedeem({
+            userAddress: USER,
+            userUnlockIndex,
+          });
+
+        const evidenceResult =
+          result as any;
+
+        expect(
+          evidenceResult.correlationId
+        ).toBe(CORRELATION_ID);
+
+        expect(
+          evidenceResult.evidenceVersion
+        ).toBe("1.0");
+
+        expect(
+          evidenceResult.evidenceEnabled
+        ).toBe(true);
+
+        expect(
+          evidenceResult.preparedPayloadHash
+        ).toMatch(
+          /^0x[a-fA-F0-9]{64}$/
+        );
+
+        expect(
+          result.bundle.totalSteps
+        ).toBe(1);
+
+        expect(
+          result.bundle.steps
+        ).toHaveLength(1);
+
+        const redeem =
+          result.bundle.steps[0];
+
+        expect(redeem.to)
+          .toBe(EXECUTOR);
+
+        expect(redeem.value)
+          .toBe("0");
+
+        expect(redeem.chainId)
+          .toBe(43114);
+
+        const executorInterface =
+          new ethers.Interface(
+            PANORAMA_EXECUTOR_ABI_EXECUTE
+          );
+
+        const decoded =
+          executorInterface.decodeFunctionData(
+            "execute",
+            redeem.data
+          );
+
+        expect(decoded[0]).toBe(
+          ethers.keccak256(
+            ethers.toUtf8Bytes("savax")
+          )
+        );
+
+        expect(decoded[1]).toBe(
+          SAVAX_SELECTORS.REDEEM
+        );
+
+        expect(decoded[2])
+          .toHaveLength(0);
+
+        const adapterDecoded =
+          ethers.AbiCoder
+            .defaultAbiCoder()
+            .decode(
+              ["uint256", "address"],
+              decoded[4]
+            );
+
+        expect(
+          adapterDecoded[0].toString()
+        ).toBe(
+          userUnlockIndex.toString()
+        );
+
+        expect(
+          adapterDecoded[1]
+            .toLowerCase()
+        ).toBe(
+          USER.toLowerCase()
+        );
+
+        expect(result.metadata)
+          .toEqual({
+            action: "redeem",
+            userUnlockIndex,
+          });
+
+        expect(
+          mockPersistEvidenceIntent
+        ).toHaveBeenCalledTimes(1);
+
+        expect(
+          mockPersistPreparedEvidence
+        ).toHaveBeenCalledTimes(1);
+
+        expect(
+          mockPersistEvidenceIntent
+            .mock.invocationCallOrder[0]
+        ).toBeLessThan(
+          mockEstimateGas
+            .mock.invocationCallOrder[0]
+        );
+
+        expect(
+          mockEstimateGas
+            .mock.invocationCallOrder[0]
+        ).toBeLessThan(
+          mockPersistPreparedEvidence
+            .mock.invocationCallOrder[0]
+        );
+      }
+    );
+  }
+);
diff --git a/backend/src/modules/avax-liquid-staking/usecases/prepare-redeem.usecase.ts b/backend/src/modules/avax-liquid-staking/usecases/prepare-redeem.usecase.ts
index d14d10f..dfec6df 100644
--- a/backend/src/modules/avax-liquid-staking/usecases/prepare-redeem.usecase.ts
+++ b/backend/src/modules/avax-liquid-staking/usecases/prepare-redeem.usecase.ts
@@ -4,6 +4,9 @@ import { encodeProtocolId, getDeadline } from "../../../utils/encoding";
 import { BundleBuilder, SAVAX_SELECTORS } from "../../../shared/bundle-builder";
 import { TransactionBundle } from "../../../types/transaction";
 import { AppError } from "../../../shared/errorCodes";
+import {
+  prepareEvidenceBoundBundle,
+} from "../../../shared/services/evidence-bound-preparation.service";
 
 export interface PrepareRedeemRequest {
   userAddress: string;
@@ -11,6 +14,10 @@ export interface PrepareRedeemRequest {
 }
 
 export interface PrepareRedeemResponse {
+  correlationId: string;
+  evidenceVersion: string;
+  evidenceEnabled: boolean;
+  preparedPayloadHash: string;
   bundle: TransactionBundle;
   metadata: {
     action: "redeem";
@@ -18,39 +25,85 @@ export interface PrepareRedeemResponse {
   };
 }
 
-export async function executePrepareRedeem(req: PrepareRedeemRequest): Promise {
-  const chain        = getChainConfig("avalanche");
-  const executorAddr = chain.contracts.panoramaExecutor;
-  if (!executorAddr) throw new AppError("INTERNAL_ERROR", "PanoramaExecutor not deployed on Avalanche");
-
-  if (req.userUnlockIndex < 0) throw new AppError("INVALID_AMOUNT", "userUnlockIndex must be >= 0");
-
-  const protocolId = encodeProtocolId("savax");
-  const builder    = new BundleBuilder(chain.chainId);
-  const deadline   = getDeadline(20);
-
-  // redeem(uint256 unlockIndex, address recipient)
-  const adapterData = ethers.AbiCoder.defaultAbiCoder().encode(
-    ["uint256", "address"],
-    [req.userUnlockIndex, req.userAddress]
-  );
-
-  builder.addExecute(
-    protocolId,
-    SAVAX_SELECTORS.REDEEM,
-    [],
-    deadline,
-    adapterData,
-    0n,
-    executorAddr,
-    `Redeem AVAX from unlock request #${req.userUnlockIndex}`
-  );
-
-  return {
-    bundle: await builder.buildWithGas(`Redeem AVAX from unlock request #${req.userUnlockIndex}`, req.userAddress),
-    metadata: {
+export async function executePrepareRedeem(
+  req: PrepareRedeemRequest
+): Promise {
+  const chain = getChainConfig("avalanche");
+  const executorAddr =
+    chain.contracts.panoramaExecutor;
+
+  if (!executorAddr) {
+    throw new AppError(
+      "INTERNAL_ERROR",
+      "PanoramaExecutor not deployed on Avalanche"
+    );
+  }
+
+  if (req.userUnlockIndex < 0) {
+    throw new AppError(
+      "INVALID_AMOUNT",
+      "userUnlockIndex must be >= 0"
+    );
+  }
+
+  return prepareEvidenceBoundBundle({
+    intent: {
       action: "redeem",
-      userUnlockIndex: req.userUnlockIndex,
+      chainId: chain.chainId,
+      network: "avalanche-c-chain",
+      walletAddress: req.userAddress,
+      assetIn: "sAVAX_UNLOCK_REQUEST",
+      assetOut: "AVAX",
+      amountRaw:
+        req.userUnlockIndex.toString(),
     },
-  };
+
+    prepare: async () => {
+      const protocolId =
+        encodeProtocolId("savax");
+
+      const builder =
+        new BundleBuilder(chain.chainId);
+
+      const deadline =
+        getDeadline(20);
+
+      const adapterData =
+        ethers.AbiCoder
+          .defaultAbiCoder()
+          .encode(
+            ["uint256", "address"],
+            [
+              req.userUnlockIndex,
+              req.userAddress,
+            ]
+          );
+
+      builder.addExecute(
+        protocolId,
+        SAVAX_SELECTORS.REDEEM,
+        [],
+        deadline,
+        adapterData,
+        0n,
+        executorAddr,
+        `Redeem AVAX from unlock request #${req.userUnlockIndex}`
+      );
+
+      const bundle =
+        await builder.buildWithGas(
+          `Redeem AVAX from unlock request #${req.userUnlockIndex}`,
+          req.userAddress
+        );
+
+      return {
+        bundle,
+        metadata: {
+          action: "redeem" as const,
+          userUnlockIndex:
+            req.userUnlockIndex,
+        },
+      };
+    },
+  });
 }

From 967e7b3313c486db00dd5c817b8a65d40edd8240 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= 
Date: Sun, 30 Aug 2026 23:04:24 +0200
Subject: [PATCH 12/18] feat: evidence-bind Avalanche bridge source

---
 .../bridge-evidence-two-phase.test.ts         | 428 ++++++++++++++++++
 .../controllers/avax-swap.controller.ts       |  40 ++
 .../avax-swap/routes/avax-swap.routes.ts      |  53 +++
 .../services/bridge-evidence.service.ts       | 234 ++++++++++
 4 files changed, 755 insertions(+)
 create mode 100644 backend/src/__tests__/modules/avax-swap/bridge-evidence-two-phase.test.ts
 create mode 100644 backend/src/modules/avax-swap/services/bridge-evidence.service.ts

diff --git a/backend/src/__tests__/modules/avax-swap/bridge-evidence-two-phase.test.ts b/backend/src/__tests__/modules/avax-swap/bridge-evidence-two-phase.test.ts
new file mode 100644
index 0000000..d80c11d
--- /dev/null
+++ b/backend/src/__tests__/modules/avax-swap/bridge-evidence-two-phase.test.ts
@@ -0,0 +1,428 @@
+import {
+  beforeEach,
+  describe,
+  expect,
+  it,
+  vi,
+} from "vitest";
+
+const CORRELATION_ID =
+  "99999999-aaaa-4bbb-8ccc-eeeeeeeeeeee";
+
+const USER =
+  "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
+
+const SOURCE_TOKEN =
+  "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E";
+
+const DEST_TOKEN =
+  "0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
+
+const {
+  mockCreateEvidenceCorrelation,
+  mockPersistEvidenceIntent,
+  mockPersistPreparedEvidence,
+  mockGetTransactionEvidence,
+} = vi.hoisted(() => ({
+  mockCreateEvidenceCorrelation: vi.fn(),
+  mockPersistEvidenceIntent: vi.fn(),
+  mockPersistPreparedEvidence: vi.fn(),
+  mockGetTransactionEvidence: vi.fn(),
+}));
+
+vi.mock(
+  "../../../shared/services/transaction-evidence.service",
+  () => ({
+    createEvidenceCorrelation:
+      mockCreateEvidenceCorrelation,
+    persistEvidenceIntent:
+      mockPersistEvidenceIntent,
+    persistPreparedEvidence:
+      mockPersistPreparedEvidence,
+    getTransactionEvidence:
+      mockGetTransactionEvidence,
+  })
+);
+
+import {
+  beginAvaxBridgeEvidence,
+  commitAvaxBridgeEvidence,
+} from "../../../modules/avax-swap/services/bridge-evidence.service";
+
+describe(
+  "Avalanche bridge source two-phase evidence boundary",
+  () => {
+    beforeEach(() => {
+      vi.clearAllMocks();
+
+      mockCreateEvidenceCorrelation.mockReturnValue({
+        correlationId: CORRELATION_ID,
+        evidenceVersion: "1.0",
+        enabled: true,
+      });
+
+      mockPersistEvidenceIntent.mockResolvedValue(
+        undefined
+      );
+
+      mockPersistPreparedEvidence.mockResolvedValue({
+        correlationId: CORRELATION_ID,
+        evidenceVersion: "1.0",
+        evidenceEnabled: true,
+        preparedPayloadHash:
+          "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+      });
+    });
+
+    it(
+      "persists the Avalanche bridge intent before returning a correlation",
+      async () => {
+        const result =
+          await beginAvaxBridgeEvidence({
+            userAddress: USER,
+            destinationChainId: 1,
+            sourceToken: SOURCE_TOKEN,
+            destinationToken: DEST_TOKEN,
+            amountRaw: "1000000",
+          });
+
+        expect(
+          mockPersistEvidenceIntent
+        ).toHaveBeenCalledTimes(1);
+
+        const persisted =
+          mockPersistEvidenceIntent.mock.calls[0][0];
+
+        expect(persisted).toMatchObject({
+          correlationId: CORRELATION_ID,
+          action: "bridge-source:1",
+          chainId: 43114,
+          network: "avalanche-c-chain",
+          walletAddress: USER,
+          assetIn: SOURCE_TOKEN,
+          assetOut: DEST_TOKEN,
+          amountRaw: "1000000",
+        });
+
+        expect(result).toEqual({
+          correlationId: CORRELATION_ID,
+          evidenceVersion: "1.0",
+          evidenceEnabled: true,
+        });
+      }
+    );
+
+    it(
+      "reloads the persisted intent and commits the exact ordered Avalanche bundle",
+      async () => {
+        mockGetTransactionEvidence.mockResolvedValue({
+          evidence: {
+            correlationId: CORRELATION_ID,
+            evidenceVersion: "1.0",
+            action: "bridge-source:1",
+            chainId: 43114,
+            network: "avalanche-c-chain",
+            walletAddress: USER.toLowerCase(),
+            assetIn: SOURCE_TOKEN.toLowerCase(),
+            assetOut: DEST_TOKEN.toLowerCase(),
+            amountRaw: "1000000",
+            createdAt:
+              "2026-08-30T20:00:00.000Z",
+            status: "intent-recorded",
+          },
+          steps: [],
+        });
+
+        const steps = [
+          {
+            to: SOURCE_TOKEN,
+            data:
+              "0x095ea7b300000000000000000000000011111111111111111111111111111111111111110000000000000000000000000000000000000000000000000000000000000000",
+            value: "0",
+            chainId: 43114,
+            description:
+              "Reset bridge allowance",
+          },
+          {
+            to: SOURCE_TOKEN,
+            data:
+              "0x095ea7b30000000000000000000000001111111111111111111111111111111111111111000000000000000000000000000000000000000000000000000000000f4240",
+            value: "0",
+            chainId: 43114,
+            description:
+              "Approve bridge",
+          },
+          {
+            to:
+              "0x2222222222222222222222222222222222222222",
+            data: "0x12345678",
+            value: "0",
+            chainId: 43114,
+            description:
+              "Bridge source transaction",
+          },
+        ];
+
+        const result =
+          await commitAvaxBridgeEvidence({
+            correlationId: CORRELATION_ID,
+            destinationChainId: 1,
+            provider: "thirdweb",
+            steps,
+          });
+
+        expect(
+          mockGetTransactionEvidence
+        ).toHaveBeenCalledWith(
+          CORRELATION_ID
+        );
+
+        expect(
+          mockPersistPreparedEvidence
+        ).toHaveBeenCalledTimes(1);
+
+        const [
+          persistedIntent,
+          persistedBundle,
+          metadata,
+        ] =
+          mockPersistPreparedEvidence.mock.calls[0];
+
+        expect(persistedIntent).toMatchObject({
+          correlationId: CORRELATION_ID,
+          action: "bridge-source:1",
+          chainId: 43114,
+          network: "avalanche-c-chain",
+          walletAddress: USER.toLowerCase(),
+          amountRaw: "1000000",
+        });
+
+        expect(persistedBundle.steps)
+          .toEqual(steps);
+
+        expect(persistedBundle.totalSteps)
+          .toBe(3);
+
+        expect(metadata).toMatchObject({
+          action: "bridge-source",
+          destinationChainId: 1,
+          provider: "thirdweb",
+        });
+
+        expect(result.preparedPayloadHash)
+          .toMatch(/^0x[a-fA-F0-9]{64}$/);
+      }
+    );
+
+    it(
+      "refuses to commit a bundle containing a non-Avalanche source transaction",
+      async () => {
+        mockGetTransactionEvidence.mockResolvedValue({
+          evidence: {
+            correlationId: CORRELATION_ID,
+            action: "bridge-source:1",
+            chainId: 43114,
+            network: "avalanche-c-chain",
+            walletAddress: USER.toLowerCase(),
+            assetIn: SOURCE_TOKEN.toLowerCase(),
+            assetOut: DEST_TOKEN.toLowerCase(),
+            amountRaw: "1000000",
+            createdAt:
+              "2026-08-30T20:00:00.000Z",
+            status: "intent-recorded",
+          },
+          steps: [],
+        });
+
+        await expect(
+          commitAvaxBridgeEvidence({
+            correlationId: CORRELATION_ID,
+            destinationChainId: 1,
+            provider: "thirdweb",
+            steps: [
+              {
+                to:
+                  "0x2222222222222222222222222222222222222222",
+                data: "0x12345678",
+                value: "0",
+                chainId: 1,
+                description: "Wrong-chain step",
+              },
+            ],
+          })
+        ).rejects.toThrow(
+          "Avalanche bridge source evidence may only commit chain 43114 transactions"
+        );
+
+        expect(
+          mockPersistPreparedEvidence
+        ).not.toHaveBeenCalled();
+      }
+    );
+
+    it(
+      "refuses to prepare an empty Avalanche source bundle",
+      async () => {
+        await expect(
+          commitAvaxBridgeEvidence({
+            correlationId: CORRELATION_ID,
+            destinationChainId: 1,
+            provider: "thirdweb",
+            steps: [],
+          })
+        ).rejects.toThrow(
+          "Avalanche bridge source evidence requires at least one transaction"
+        );
+
+        expect(
+          mockGetTransactionEvidence
+        ).not.toHaveBeenCalled();
+
+        expect(
+          mockPersistPreparedEvidence
+        ).not.toHaveBeenCalled();
+      }
+    );
+
+    it(
+      "refuses to prepare bridge evidence that already contains prepared steps",
+      async () => {
+        mockGetTransactionEvidence.mockResolvedValue({
+          evidence: {
+            correlationId: CORRELATION_ID,
+            action: "bridge-source:1",
+            chainId: 43114,
+            network: "avalanche-c-chain",
+            walletAddress: USER.toLowerCase(),
+            assetIn: SOURCE_TOKEN.toLowerCase(),
+            assetOut: DEST_TOKEN.toLowerCase(),
+            amountRaw: "1000000",
+            createdAt:
+              "2026-08-30T20:00:00.000Z",
+            status: "intent-recorded",
+          },
+          steps: [
+            {
+              correlationId: CORRELATION_ID,
+              stepIndex: 0,
+              chainId: 43114,
+            },
+          ],
+        });
+
+        await expect(
+          commitAvaxBridgeEvidence({
+            correlationId: CORRELATION_ID,
+            destinationChainId: 1,
+            provider: "thirdweb",
+            steps: [
+              {
+                to:
+                  "0x2222222222222222222222222222222222222222",
+                data: "0x12345678",
+                value: "0",
+                chainId: 43114,
+              },
+            ],
+          })
+        ).rejects.toThrow(
+          "Bridge evidence already contains prepared transaction steps"
+        );
+
+        expect(
+          mockPersistPreparedEvidence
+        ).not.toHaveBeenCalled();
+      }
+    );
+
+    it(
+      "refuses to prepare bridge evidence outside the intent-recorded state",
+      async () => {
+        mockGetTransactionEvidence.mockResolvedValue({
+          evidence: {
+            correlationId: CORRELATION_ID,
+            action: "bridge-source:1",
+            chainId: 43114,
+            network: "avalanche-c-chain",
+            walletAddress: USER.toLowerCase(),
+            assetIn: SOURCE_TOKEN.toLowerCase(),
+            assetOut: DEST_TOKEN.toLowerCase(),
+            amountRaw: "1000000",
+            createdAt:
+              "2026-08-30T20:00:00.000Z",
+            status: "prepared",
+          },
+          steps: [],
+        });
+
+        await expect(
+          commitAvaxBridgeEvidence({
+            correlationId: CORRELATION_ID,
+            destinationChainId: 1,
+            provider: "thirdweb",
+            steps: [
+              {
+                to:
+                  "0x2222222222222222222222222222222222222222",
+                data: "0x12345678",
+                value: "0",
+                chainId: 43114,
+              },
+            ],
+          })
+        ).rejects.toThrow(
+          "Bridge evidence cannot be prepared from status prepared"
+        );
+
+        expect(
+          mockPersistPreparedEvidence
+        ).not.toHaveBeenCalled();
+      }
+    );
+
+    it(
+      "refuses a destination chain that does not match the persisted bridge intent",
+      async () => {
+        mockGetTransactionEvidence.mockResolvedValue({
+          evidence: {
+            correlationId: CORRELATION_ID,
+            action: "bridge-source:1",
+            chainId: 43114,
+            network: "avalanche-c-chain",
+            walletAddress: USER.toLowerCase(),
+            assetIn: SOURCE_TOKEN.toLowerCase(),
+            assetOut: DEST_TOKEN.toLowerCase(),
+            amountRaw: "1000000",
+            createdAt:
+              "2026-08-30T20:00:00.000Z",
+            status: "intent-recorded",
+          },
+          steps: [],
+        });
+
+        await expect(
+          commitAvaxBridgeEvidence({
+            correlationId: CORRELATION_ID,
+            destinationChainId: 8453,
+            provider: "thirdweb",
+            steps: [
+              {
+                to:
+                  "0x2222222222222222222222222222222222222222",
+                data: "0x12345678",
+                value: "0",
+                chainId: 43114,
+              },
+            ],
+          })
+        ).rejects.toThrow(
+          "Bridge destination does not match persisted evidence intent"
+        );
+
+        expect(
+          mockPersistPreparedEvidence
+        ).not.toHaveBeenCalled();
+      }
+    );
+  }
+);
diff --git a/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts b/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
index 6bc1cf8..5545abd 100644
--- a/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
+++ b/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
@@ -12,6 +12,10 @@ import {
   isPhase2EvidenceAdmin,
 } from "../../../shared/services/transaction-evidence.service";
 import { AppError } from "../../../shared/errorCodes";
+import {
+  beginAvaxBridgeEvidence,
+  commitAvaxBridgeEvidence,
+} from "../services/bridge-evidence.service";
 
 export const getQuote = asyncHandler(async (req: Request, res: Response) => {
   const result = await executeGetAvaxQuote({
@@ -39,6 +43,42 @@ export const getPairs = asyncHandler(async (_req: Request, res: Response) => {
   res.json({ pairs: getEnabledSwapPairs() });
 });
 
+export const beginBridgeEvidence = asyncHandler(
+  async (req: Request, res: Response) => {
+    const result = await beginAvaxBridgeEvidence({
+      userAddress: req.body.userAddress,
+      destinationChainId: Number(req.body.destinationChainId),
+      sourceToken: req.body.sourceToken,
+      destinationToken: req.body.destinationToken,
+      amountRaw: req.body.amountRaw,
+    });
+
+    res.json(result);
+  }
+);
+
+export const commitBridgeEvidence = asyncHandler(
+  async (req: Request, res: Response) => {
+    const { correlationId } = req.params;
+
+    if (!correlationId || typeof correlationId !== "string") {
+      throw new AppError(
+        "MISSING_FIELD",
+        "correlationId is required"
+      );
+    }
+
+    const result = await commitAvaxBridgeEvidence({
+      correlationId,
+      destinationChainId: Number(req.body.destinationChainId),
+      provider: req.body.provider,
+      steps: req.body.steps,
+    });
+
+    res.json(result);
+  }
+);
+
 export const submitEvidence = asyncHandler(async (req: Request, res: Response) => {
   const { correlationId } = req.params;
   const { stepIndex, txHash, executionMechanism, providerMetadata } = req.body;
diff --git a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
index b669899..7d86086 100644
--- a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
+++ b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
@@ -48,6 +48,59 @@ avaxSwapRoutes.post(
   ctrl.prepareSwap
 );
 
+/**
+ * POST /avax/swap/bridge/evidence/intent
+ *
+ * T1 boundary for client-prepared Avalanche bridge operations.
+ * Persists the source-chain intent before Thirdweb/LayerSwap
+ * transaction preparation occurs.
+ *
+ * Body: {
+ *   userAddress,
+ *   destinationChainId,
+ *   sourceToken,
+ *   destinationToken?,
+ *   amountRaw
+ * }
+ */
+avaxSwapRoutes.post(
+  "/bridge/evidence/intent",
+  validateRequired(
+    "userAddress",
+    "destinationChainId",
+    "sourceToken",
+    "amountRaw"
+  ),
+  validateAddress("userAddress"),
+  validateAddress("sourceToken"),
+  executionTimeout(),
+  ctrl.beginBridgeEvidence
+);
+
+/**
+ * POST /avax/swap/bridge/evidence/:correlationId/prepare
+ *
+ * T3 boundary for client-prepared Avalanche bridge operations.
+ * Commits the exact ordered source transaction bundle after provider
+ * preparation and before the first wallet signature.
+ *
+ * Body: {
+ *   destinationChainId,
+ *   provider,
+ *   steps
+ * }
+ */
+avaxSwapRoutes.post(
+  "/bridge/evidence/:correlationId/prepare",
+  validateRequired(
+    "destinationChainId",
+    "provider",
+    "steps"
+  ),
+  executionTimeout(),
+  ctrl.commitBridgeEvidence
+);
+
 /**
  * POST /avax/swap/evidence/:correlationId/submissions
  * Records a client-broadcast transaction hash and independently verifies
diff --git a/backend/src/modules/avax-swap/services/bridge-evidence.service.ts b/backend/src/modules/avax-swap/services/bridge-evidence.service.ts
new file mode 100644
index 0000000..7c906c8
--- /dev/null
+++ b/backend/src/modules/avax-swap/services/bridge-evidence.service.ts
@@ -0,0 +1,234 @@
+import {
+  createEvidenceCorrelation,
+  EvidenceIntentInput,
+  getTransactionEvidence,
+  persistEvidenceIntent,
+  persistPreparedEvidence,
+  PreparedEvidenceResult,
+} from "../../../shared/services/transaction-evidence.service";
+import {
+  PreparedTransaction,
+  TransactionBundle,
+} from "../../../types/transaction";
+
+const AVALANCHE_CHAIN_ID = 43114;
+const AVALANCHE_NETWORK = "avalanche-c-chain";
+
+export interface BeginAvaxBridgeEvidenceInput {
+  userAddress: string;
+  destinationChainId: number;
+  sourceToken: string;
+  destinationToken?: string;
+  amountRaw: string;
+}
+
+export interface BeginAvaxBridgeEvidenceResult {
+  correlationId: string;
+  evidenceVersion: string;
+  evidenceEnabled: boolean;
+}
+
+export interface CommitAvaxBridgeEvidenceInput {
+  correlationId: string;
+  destinationChainId: number;
+  provider: string;
+  steps: PreparedTransaction[];
+}
+
+function bridgeAction(destinationChainId: number): string {
+  return `bridge-source:${destinationChainId}`;
+}
+
+function isEvmAddress(value: string | undefined): boolean {
+  return (
+    typeof value === "string" &&
+    /^0x[a-fA-F0-9]{40}$/.test(value)
+  );
+}
+
+export async function beginAvaxBridgeEvidence(
+  input: BeginAvaxBridgeEvidenceInput
+): Promise {
+  if (
+    !Number.isInteger(input.destinationChainId) ||
+    input.destinationChainId <= 0 ||
+    input.destinationChainId === AVALANCHE_CHAIN_ID
+  ) {
+    throw new Error(
+      "Avalanche bridge source evidence requires a different valid destination chain"
+    );
+  }
+
+  if (
+    typeof input.userAddress !== "string" ||
+    !isEvmAddress(input.userAddress)
+  ) {
+    throw new Error("A valid source wallet address is required");
+  }
+
+  if (
+    typeof input.sourceToken !== "string" ||
+    !isEvmAddress(input.sourceToken)
+  ) {
+    throw new Error("A valid Avalanche source token is required");
+  }
+
+  if (
+    typeof input.amountRaw !== "string" ||
+    !/^[0-9]+$/.test(input.amountRaw) ||
+    BigInt(input.amountRaw) <= 0n
+  ) {
+    throw new Error("A positive raw bridge amount is required");
+  }
+
+  const evidence = createEvidenceCorrelation();
+
+  const intent: EvidenceIntentInput = {
+    correlationId: evidence.correlationId,
+    createdAt: new Date().toISOString(),
+    action: bridgeAction(input.destinationChainId),
+    chainId: AVALANCHE_CHAIN_ID,
+    network: AVALANCHE_NETWORK,
+    walletAddress: input.userAddress,
+    assetIn: input.sourceToken,
+    // Cross-chain destinations are not necessarily EVM-addressed
+    // (e.g. TON). Persist assetOut only when it is an EVM address.
+    assetOut: isEvmAddress(input.destinationToken)
+      ? input.destinationToken
+      : undefined,
+    amountRaw: input.amountRaw,
+  };
+
+  await persistEvidenceIntent(intent);
+
+  return {
+    correlationId: evidence.correlationId,
+    evidenceVersion: evidence.evidenceVersion,
+    evidenceEnabled: evidence.enabled,
+  };
+}
+
+export async function commitAvaxBridgeEvidence(
+  input: CommitAvaxBridgeEvidenceInput
+): Promise {
+  if (
+    !Number.isInteger(input.destinationChainId) ||
+    input.destinationChainId <= 0 ||
+    input.destinationChainId === AVALANCHE_CHAIN_ID
+  ) {
+    throw new Error(
+      "Avalanche bridge source evidence requires a different valid destination chain"
+    );
+  }
+
+  if (
+    typeof input.provider !== "string" ||
+    !input.provider.trim()
+  ) {
+    throw new Error("Bridge provider is required");
+  }
+
+  if (!Array.isArray(input.steps) || input.steps.length === 0) {
+    throw new Error(
+      "Avalanche bridge source evidence requires at least one transaction"
+    );
+  }
+
+  if (
+    input.steps.some(
+      (step) => Number(step.chainId) !== AVALANCHE_CHAIN_ID
+    )
+  ) {
+    throw new Error(
+      "Avalanche bridge source evidence may only commit chain 43114 transactions"
+    );
+  }
+
+  const stored = await getTransactionEvidence(
+    input.correlationId
+  );
+
+  const evidence = stored.evidence as any;
+  const expectedAction = bridgeAction(
+    input.destinationChainId
+  );
+
+  if (Number(evidence.chainId) !== AVALANCHE_CHAIN_ID) {
+    throw new Error(
+      "Persisted evidence intent is not an Avalanche source operation"
+    );
+  }
+
+  if (evidence.action !== expectedAction) {
+    throw new Error(
+      "Bridge destination does not match persisted evidence intent"
+    );
+  }
+
+  if (
+    evidence.status &&
+    evidence.status !== "intent-recorded"
+  ) {
+    throw new Error(
+      `Bridge evidence cannot be prepared from status ${evidence.status}`
+    );
+  }
+
+  if (
+    Array.isArray(stored.steps) &&
+    stored.steps.length > 0
+  ) {
+    throw new Error(
+      "Bridge evidence already contains prepared transaction steps"
+    );
+  }
+
+  const createdAt =
+    typeof evidence.createdAt === "string"
+      ? evidence.createdAt
+      : typeof evidence.intent?.requestTimestamp === "string"
+        ? evidence.intent.requestTimestamp
+        : undefined;
+
+  if (!createdAt) {
+    throw new Error(
+      "Persisted bridge evidence intent is missing its creation timestamp"
+    );
+  }
+
+  const persistedIntent: EvidenceIntentInput = {
+    correlationId: input.correlationId,
+    createdAt,
+    action: evidence.action,
+    chainId: Number(evidence.chainId),
+    network: evidence.network,
+    walletAddress: evidence.walletAddress,
+    assetIn: evidence.assetIn || undefined,
+    assetOut: evidence.assetOut || undefined,
+    amountRaw: evidence.amountRaw || undefined,
+    slippageBps:
+      typeof evidence.slippageBps === "number"
+        ? evidence.slippageBps
+        : undefined,
+  };
+
+  const bundle: TransactionBundle = {
+    // Preserve caller-supplied order exactly. This order becomes
+    // the committed signing order.
+    steps: input.steps,
+    totalSteps: input.steps.length,
+    summary:
+      `Avalanche bridge source via ${input.provider} ` +
+      `to chain ${input.destinationChainId}`,
+  };
+
+  return persistPreparedEvidence(
+    persistedIntent,
+    bundle,
+    {
+      action: "bridge-source",
+      destinationChainId: input.destinationChainId,
+      provider: input.provider,
+    }
+  );
+}

From e50915b47fcc26d77f04aa27b6c09cdc02088f0e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= 
Date: Sun, 30 Aug 2026 23:20:14 +0200
Subject: [PATCH 13/18] feat: evidence-bind Avalanche bridge destination

---
 .../bridge-destination-evidence.test.ts       | 342 ++++++++++++++++++
 .../controllers/avax-swap.controller.ts       |  47 +++
 .../avax-swap/routes/avax-swap.routes.ts      |  37 ++
 .../services/bridge-evidence.service.ts       | 217 +++++++++++
 4 files changed, 643 insertions(+)
 create mode 100644 backend/src/__tests__/modules/avax-swap/bridge-destination-evidence.test.ts

diff --git a/backend/src/__tests__/modules/avax-swap/bridge-destination-evidence.test.ts b/backend/src/__tests__/modules/avax-swap/bridge-destination-evidence.test.ts
new file mode 100644
index 0000000..d02cb69
--- /dev/null
+++ b/backend/src/__tests__/modules/avax-swap/bridge-destination-evidence.test.ts
@@ -0,0 +1,342 @@
+import {
+  beforeEach,
+  describe,
+  expect,
+  it,
+  vi,
+} from "vitest";
+
+const CORRELATION_ID =
+  "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
+
+const USER =
+  "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
+
+const DEST_TOKEN =
+  "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E";
+
+const {
+  mockCreateEvidenceCorrelation,
+  mockPersistEvidenceIntent,
+  mockPersistPreparedEvidence,
+  mockGetTransactionEvidence,
+} = vi.hoisted(() => ({
+  mockCreateEvidenceCorrelation: vi.fn(),
+  mockPersistEvidenceIntent: vi.fn(),
+  mockPersistPreparedEvidence: vi.fn(),
+  mockGetTransactionEvidence: vi.fn(),
+}));
+
+vi.mock(
+  "../../../shared/services/transaction-evidence.service",
+  () => ({
+    createEvidenceCorrelation:
+      mockCreateEvidenceCorrelation,
+    persistEvidenceIntent:
+      mockPersistEvidenceIntent,
+    persistPreparedEvidence:
+      mockPersistPreparedEvidence,
+    getTransactionEvidence:
+      mockGetTransactionEvidence,
+  })
+);
+
+import {
+  beginAvaxBridgeDestinationEvidence,
+  commitAvaxBridgeDestinationEvidence,
+} from "../../../modules/avax-swap/services/bridge-evidence.service";
+
+describe(
+  "Avalanche bridge destination evidence boundary",
+  () => {
+    beforeEach(() => {
+      vi.clearAllMocks();
+
+      mockCreateEvidenceCorrelation
+        .mockReturnValue({
+          correlationId: CORRELATION_ID,
+          evidenceVersion: "1.0",
+          enabled: true,
+        });
+
+      mockPersistEvidenceIntent
+        .mockResolvedValue(undefined);
+
+      mockPersistPreparedEvidence
+        .mockResolvedValue({
+          correlationId: CORRELATION_ID,
+          evidenceVersion: "1.0",
+          evidenceEnabled: true,
+          preparedPayloadHash:
+            "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+        });
+    });
+
+    it(
+      "persists an Avalanche destination intent bound to its source chain",
+      async () => {
+        await beginAvaxBridgeDestinationEvidence({
+          userAddress: USER,
+          sourceChainId: 1,
+          destinationToken: DEST_TOKEN,
+          amountRaw: "1000000",
+        });
+
+        expect(
+          mockPersistEvidenceIntent
+        ).toHaveBeenCalledTimes(1);
+
+        expect(
+          mockPersistEvidenceIntent
+            .mock.calls[0][0]
+        ).toMatchObject({
+          correlationId: CORRELATION_ID,
+          action: "bridge-destination:1",
+          chainId: 43114,
+          network: "avalanche-c-chain",
+          walletAddress: USER,
+          assetOut: DEST_TOKEN,
+          amountRaw: "1000000",
+        });
+      }
+    );
+
+    it(
+      "commits only ordered Avalanche destination transactions",
+      async () => {
+        mockGetTransactionEvidence
+          .mockResolvedValue({
+            evidence: {
+              correlationId: CORRELATION_ID,
+              action: "bridge-destination:1",
+              chainId: 43114,
+              network: "avalanche-c-chain",
+              walletAddress:
+                USER.toLowerCase(),
+              assetOut:
+                DEST_TOKEN.toLowerCase(),
+              amountRaw: "1000000",
+              createdAt:
+                "2026-08-30T21:00:00.000Z",
+              status: "intent-recorded",
+            },
+            steps: [],
+          });
+
+        const steps = [
+          {
+            to:
+              "0x2222222222222222222222222222222222222222",
+            data: "0x12345678",
+            value: "0",
+            chainId: 43114,
+            description:
+              "Bridge destination transaction",
+          },
+        ];
+
+        await commitAvaxBridgeDestinationEvidence({
+          correlationId: CORRELATION_ID,
+          sourceChainId: 1,
+          provider: "thirdweb",
+          steps,
+        });
+
+        const [
+          persistedIntent,
+          persistedBundle,
+          metadata,
+        ] =
+          mockPersistPreparedEvidence
+            .mock.calls[0];
+
+        expect(persistedIntent)
+          .toMatchObject({
+            action:
+              "bridge-destination:1",
+            chainId: 43114,
+          });
+
+        expect(persistedBundle.steps)
+          .toEqual(steps);
+
+        expect(metadata)
+          .toMatchObject({
+            action:
+              "bridge-destination",
+            sourceChainId: 1,
+            provider: "thirdweb",
+          });
+      }
+    );
+
+    it(
+      "rejects a source chain that does not match the persisted destination intent",
+      async () => {
+        mockGetTransactionEvidence
+          .mockResolvedValue({
+            evidence: {
+              correlationId: CORRELATION_ID,
+              action: "bridge-destination:10",
+              chainId: 43114,
+              network: "avalanche-c-chain",
+              walletAddress: USER,
+              assetOut: DEST_TOKEN,
+              amountRaw: "1000000",
+              createdAt:
+                "2026-08-30T21:00:00.000Z",
+              status: "intent-recorded",
+            },
+            steps: [],
+          });
+
+        await expect(
+          commitAvaxBridgeDestinationEvidence({
+            correlationId: CORRELATION_ID,
+            sourceChainId: 1,
+            provider: "thirdweb",
+            steps: [
+              {
+                to:
+                  "0x2222222222222222222222222222222222222222",
+                data: "0x12345678",
+                value: "0",
+                chainId: 43114,
+              },
+            ],
+          })
+        ).rejects.toThrow(
+          "Bridge source does not match persisted destination evidence intent"
+        );
+
+        expect(
+          mockPersistPreparedEvidence
+        ).not.toHaveBeenCalled();
+      }
+    );
+
+    it(
+      "rejects destination evidence outside intent-recorded lifecycle state",
+      async () => {
+        mockGetTransactionEvidence
+          .mockResolvedValue({
+            evidence: {
+              correlationId: CORRELATION_ID,
+              action: "bridge-destination:1",
+              chainId: 43114,
+              network: "avalanche-c-chain",
+              walletAddress: USER,
+              assetOut: DEST_TOKEN,
+              amountRaw: "1000000",
+              createdAt:
+                "2026-08-30T21:00:00.000Z",
+              status: "prepared",
+            },
+            steps: [],
+          });
+
+        await expect(
+          commitAvaxBridgeDestinationEvidence({
+            correlationId: CORRELATION_ID,
+            sourceChainId: 1,
+            provider: "thirdweb",
+            steps: [
+              {
+                to:
+                  "0x2222222222222222222222222222222222222222",
+                data: "0x12345678",
+                value: "0",
+                chainId: 43114,
+              },
+            ],
+          })
+        ).rejects.toThrow(
+          "Bridge destination evidence cannot be prepared from status prepared"
+        );
+
+        expect(
+          mockPersistPreparedEvidence
+        ).not.toHaveBeenCalled();
+      }
+    );
+
+    it(
+      "rejects destination evidence that already contains prepared steps",
+      async () => {
+        mockGetTransactionEvidence
+          .mockResolvedValue({
+            evidence: {
+              correlationId: CORRELATION_ID,
+              action: "bridge-destination:1",
+              chainId: 43114,
+              network: "avalanche-c-chain",
+              walletAddress: USER,
+              assetOut: DEST_TOKEN,
+              amountRaw: "1000000",
+              createdAt:
+                "2026-08-30T21:00:00.000Z",
+              status: "intent-recorded",
+            },
+            steps: [
+              {
+                stepIndex: 0,
+                chainId: 43114,
+              },
+            ],
+          });
+
+        await expect(
+          commitAvaxBridgeDestinationEvidence({
+            correlationId: CORRELATION_ID,
+            sourceChainId: 1,
+            provider: "thirdweb",
+            steps: [
+              {
+                to:
+                  "0x2222222222222222222222222222222222222222",
+                data: "0x12345678",
+                value: "0",
+                chainId: 43114,
+              },
+            ],
+          })
+        ).rejects.toThrow(
+          "Bridge destination evidence already contains prepared transaction steps"
+        );
+
+        expect(
+          mockPersistPreparedEvidence
+        ).not.toHaveBeenCalled();
+      }
+    );
+
+    it(
+      "rejects non-Avalanche destination steps",
+      async () => {
+        await expect(
+          commitAvaxBridgeDestinationEvidence({
+            correlationId:
+              CORRELATION_ID,
+            sourceChainId: 1,
+            provider: "thirdweb",
+            steps: [
+              {
+                to:
+                  "0x2222222222222222222222222222222222222222",
+                data: "0x12345678",
+                value: "0",
+                chainId: 1,
+              },
+            ],
+          })
+        ).rejects.toThrow(
+          "Avalanche bridge destination evidence may only commit chain 43114 transactions"
+        );
+
+        expect(
+          mockPersistPreparedEvidence
+        ).not.toHaveBeenCalled();
+      }
+    );
+  }
+);
diff --git a/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts b/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
index 5545abd..a94ff1e 100644
--- a/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
+++ b/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
@@ -14,7 +14,9 @@ import {
 import { AppError } from "../../../shared/errorCodes";
 import {
   beginAvaxBridgeEvidence,
+  beginAvaxBridgeDestinationEvidence,
   commitAvaxBridgeEvidence,
+  commitAvaxBridgeDestinationEvidence,
 } from "../services/bridge-evidence.service";
 
 export const getQuote = asyncHandler(async (req: Request, res: Response) => {
@@ -79,6 +81,51 @@ export const commitBridgeEvidence = asyncHandler(
   }
 );
 
+export const beginBridgeDestinationEvidence = asyncHandler(
+  async (req: Request, res: Response) => {
+    const result =
+      await beginAvaxBridgeDestinationEvidence({
+        userAddress: req.body.userAddress,
+        sourceChainId: Number(
+          req.body.sourceChainId
+        ),
+        destinationToken:
+          req.body.destinationToken,
+        amountRaw: req.body.amountRaw,
+      });
+
+    res.json(result);
+  }
+);
+
+export const commitBridgeDestinationEvidence = asyncHandler(
+  async (req: Request, res: Response) => {
+    const { correlationId } = req.params;
+
+    if (
+      !correlationId ||
+      typeof correlationId !== "string"
+    ) {
+      throw new AppError(
+        "MISSING_FIELD",
+        "correlationId is required"
+      );
+    }
+
+    const result =
+      await commitAvaxBridgeDestinationEvidence({
+        correlationId,
+        sourceChainId: Number(
+          req.body.sourceChainId
+        ),
+        provider: req.body.provider,
+        steps: req.body.steps,
+      });
+
+    res.json(result);
+  }
+);
+
 export const submitEvidence = asyncHandler(async (req: Request, res: Response) => {
   const { correlationId } = req.params;
   const { stepIndex, txHash, executionMechanism, providerMetadata } = req.body;
diff --git a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
index 7d86086..11ec993 100644
--- a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
+++ b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
@@ -101,6 +101,43 @@ avaxSwapRoutes.post(
   ctrl.commitBridgeEvidence
 );
 
+/**
+ * POST /avax/swap/bridge/destination/evidence/intent
+ *
+ * T1 boundary for Avalanche destination execution prepared
+ * client-side by a cross-chain provider.
+ */
+avaxSwapRoutes.post(
+  "/bridge/destination/evidence/intent",
+  validateRequired(
+    "userAddress",
+    "sourceChainId",
+    "destinationToken",
+    "amountRaw"
+  ),
+  validateAddress("userAddress"),
+  validateAddress("destinationToken"),
+  executionTimeout(),
+  ctrl.beginBridgeDestinationEvidence
+);
+
+/**
+ * POST /avax/swap/bridge/destination/evidence/:correlationId/prepare
+ *
+ * Commits the exact ordered Avalanche destination suffix before
+ * PanoramaBlock signs any destination-chain transaction.
+ */
+avaxSwapRoutes.post(
+  "/bridge/destination/evidence/:correlationId/prepare",
+  validateRequired(
+    "sourceChainId",
+    "provider",
+    "steps"
+  ),
+  executionTimeout(),
+  ctrl.commitBridgeDestinationEvidence
+);
+
 /**
  * POST /avax/swap/evidence/:correlationId/submissions
  * Records a client-broadcast transaction hash and independently verifies
diff --git a/backend/src/modules/avax-swap/services/bridge-evidence.service.ts b/backend/src/modules/avax-swap/services/bridge-evidence.service.ts
index 7c906c8..6817c87 100644
--- a/backend/src/modules/avax-swap/services/bridge-evidence.service.ts
+++ b/backend/src/modules/avax-swap/services/bridge-evidence.service.ts
@@ -232,3 +232,220 @@ export async function commitAvaxBridgeEvidence(
     }
   );
 }
+
+export interface BeginAvaxBridgeDestinationEvidenceInput {
+  userAddress: string;
+  sourceChainId: number;
+  destinationToken: string;
+  amountRaw: string;
+}
+
+export interface CommitAvaxBridgeDestinationEvidenceInput {
+  correlationId: string;
+  sourceChainId: number;
+  provider: string;
+  steps: PreparedTransaction[];
+}
+
+function bridgeDestinationAction(sourceChainId: number): string {
+  return `bridge-destination:${sourceChainId}`;
+}
+
+export async function beginAvaxBridgeDestinationEvidence(
+  input: BeginAvaxBridgeDestinationEvidenceInput
+): Promise {
+  if (
+    !Number.isInteger(input.sourceChainId) ||
+    input.sourceChainId <= 0 ||
+    input.sourceChainId === AVALANCHE_CHAIN_ID
+  ) {
+    throw new Error(
+      "Avalanche bridge destination evidence requires a different valid source chain"
+    );
+  }
+
+  if (
+    typeof input.userAddress !== "string" ||
+    !isEvmAddress(input.userAddress)
+  ) {
+    throw new Error(
+      "A valid destination wallet address is required"
+    );
+  }
+
+  if (
+    typeof input.destinationToken !== "string" ||
+    !isEvmAddress(input.destinationToken)
+  ) {
+    throw new Error(
+      "A valid Avalanche destination token is required"
+    );
+  }
+
+  if (
+    typeof input.amountRaw !== "string" ||
+    !/^[0-9]+$/.test(input.amountRaw) ||
+    BigInt(input.amountRaw) <= 0n
+  ) {
+    throw new Error(
+      "A positive raw bridge amount is required"
+    );
+  }
+
+  const evidence = createEvidenceCorrelation();
+
+  const intent: EvidenceIntentInput = {
+    correlationId: evidence.correlationId,
+    createdAt: new Date().toISOString(),
+    action: bridgeDestinationAction(
+      input.sourceChainId
+    ),
+    chainId: AVALANCHE_CHAIN_ID,
+    network: AVALANCHE_NETWORK,
+    walletAddress: input.userAddress,
+    assetOut: input.destinationToken,
+    amountRaw: input.amountRaw,
+  };
+
+  await persistEvidenceIntent(intent);
+
+  return {
+    correlationId: evidence.correlationId,
+    evidenceVersion: evidence.evidenceVersion,
+    evidenceEnabled: evidence.enabled,
+  };
+}
+
+export async function commitAvaxBridgeDestinationEvidence(
+  input: CommitAvaxBridgeDestinationEvidenceInput
+): Promise {
+  if (
+    !Number.isInteger(input.sourceChainId) ||
+    input.sourceChainId <= 0 ||
+    input.sourceChainId === AVALANCHE_CHAIN_ID
+  ) {
+    throw new Error(
+      "Avalanche bridge destination evidence requires a different valid source chain"
+    );
+  }
+
+  if (
+    typeof input.provider !== "string" ||
+    !input.provider.trim()
+  ) {
+    throw new Error("Bridge provider is required");
+  }
+
+  if (
+    !Array.isArray(input.steps) ||
+    input.steps.length === 0
+  ) {
+    throw new Error(
+      "Avalanche bridge destination evidence requires at least one transaction"
+    );
+  }
+
+  if (
+    input.steps.some(
+      (step) =>
+        Number(step.chainId) !==
+        AVALANCHE_CHAIN_ID
+    )
+  ) {
+    throw new Error(
+      "Avalanche bridge destination evidence may only commit chain 43114 transactions"
+    );
+  }
+
+  const stored = await getTransactionEvidence(
+    input.correlationId
+  );
+
+  const evidence = stored.evidence as any;
+
+  const expectedAction =
+    bridgeDestinationAction(
+      input.sourceChainId
+    );
+
+  if (
+    Number(evidence.chainId) !==
+    AVALANCHE_CHAIN_ID
+  ) {
+    throw new Error(
+      "Persisted evidence intent is not an Avalanche destination operation"
+    );
+  }
+
+  if (evidence.action !== expectedAction) {
+    throw new Error(
+      "Bridge source does not match persisted destination evidence intent"
+    );
+  }
+
+  if (
+    evidence.status &&
+    evidence.status !== "intent-recorded"
+  ) {
+    throw new Error(
+      `Bridge destination evidence cannot be prepared from status ${evidence.status}`
+    );
+  }
+
+  if (
+    Array.isArray(stored.steps) &&
+    stored.steps.length > 0
+  ) {
+    throw new Error(
+      "Bridge destination evidence already contains prepared transaction steps"
+    );
+  }
+
+  const createdAt =
+    typeof evidence.createdAt === "string"
+      ? evidence.createdAt
+      : typeof evidence.intent?.requestTimestamp ===
+          "string"
+        ? evidence.intent.requestTimestamp
+        : undefined;
+
+  if (!createdAt) {
+    throw new Error(
+      "Persisted bridge destination evidence intent is missing its creation timestamp"
+    );
+  }
+
+  const persistedIntent: EvidenceIntentInput = {
+    correlationId: input.correlationId,
+    createdAt,
+    action: evidence.action,
+    chainId: Number(evidence.chainId),
+    network: evidence.network,
+    walletAddress: evidence.walletAddress,
+    assetIn: evidence.assetIn || undefined,
+    assetOut: evidence.assetOut || undefined,
+    amountRaw: evidence.amountRaw || undefined,
+    slippageBps:
+      typeof evidence.slippageBps === "number"
+        ? evidence.slippageBps
+        : undefined,
+  };
+
+  const bundle: TransactionBundle = {
+    steps: input.steps,
+    totalSteps: input.steps.length,
+    summary:
+      `Avalanche bridge destination via ${input.provider} ` +
+      `from chain ${input.sourceChainId}`,
+  };
+
+  return persistPreparedEvidence(
+    persistedIntent,
+    bundle,
+    {
+      action: "bridge-destination",
+      sourceChainId: input.sourceChainId,
+      provider: input.provider,
+    }
+  );
+}

From 4824bb7185d5a123b9aac631f24026f064a3c3c6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= 
Date: Mon, 31 Aug 2026 10:12:43 +0200
Subject: [PATCH 14/18] feat: add durable evidence submission lifecycle

---
 .../transaction-evidence-lifecycle.test.ts    | 187 +++++++++++++++
 .../services/transaction-evidence.service.ts  | 213 +++++++++++++++---
 2 files changed, 363 insertions(+), 37 deletions(-)
 create mode 100644 backend/src/shared/services/__tests__/transaction-evidence-lifecycle.test.ts

diff --git a/backend/src/shared/services/__tests__/transaction-evidence-lifecycle.test.ts b/backend/src/shared/services/__tests__/transaction-evidence-lifecycle.test.ts
new file mode 100644
index 0000000..06fd9a3
--- /dev/null
+++ b/backend/src/shared/services/__tests__/transaction-evidence-lifecycle.test.ts
@@ -0,0 +1,187 @@
+import {
+  describe,
+  expect,
+  it,
+} from "vitest";
+
+import {
+  deriveEvidenceLifecycleStatus,
+} from "../transaction-evidence.service";
+
+describe(
+  "transaction evidence submission lifecycle",
+  () => {
+    const prepared = {
+      txHash: null,
+      receiptStatus: null,
+      receiptRetrievedAt: null,
+      verified: null,
+    };
+
+    const submitted = {
+      ...prepared,
+      txHash:
+        "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+    };
+
+    const confirmed = {
+      ...submitted,
+      receiptStatus: 1,
+      receiptRetrievedAt:
+        "2026-08-31T00:00:00.000Z",
+    };
+
+    const verified = {
+      ...confirmed,
+      verified: true,
+    };
+
+    it(
+      "preserves prepared before any transaction is submitted",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [prepared, prepared],
+            "prepared"
+          )
+        ).toEqual({
+          status: "prepared",
+          verificationStatus: null,
+          verified: false,
+        });
+      }
+    );
+
+    it(
+      "becomes partially-submitted after the first durable hash",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [submitted, prepared],
+            "prepared"
+          ).status
+        ).toBe("partially-submitted");
+      }
+    );
+
+    it(
+      "becomes submitted when every prepared step has a hash",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [submitted, submitted],
+            "partially-submitted"
+          ).status
+        ).toBe("submitted");
+      }
+    );
+
+    it(
+      "becomes partially-confirmed when only some receipts are durable",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [confirmed, submitted],
+            "submitted"
+          ).status
+        ).toBe("partially-confirmed");
+      }
+    );
+
+    it(
+      "becomes confirmed when every receipt is durable",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [confirmed, confirmed],
+            "partially-confirmed"
+          )
+        ).toEqual({
+          status: "confirmed",
+          verificationStatus: "pending",
+          verified: false,
+        });
+      }
+    );
+
+    it(
+      "becomes verified only when every step independently verifies",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [verified, verified],
+            "confirmed",
+            "pending"
+          )
+        ).toEqual({
+          status: "verified",
+          verificationStatus: "verified",
+          verified: true,
+        });
+      }
+    );
+
+    it(
+      "does not regress verified state when verified step evidence is rolled up again",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [verified, verified],
+            "verified",
+            "verified"
+          )
+        ).toEqual({
+          status: "verified",
+          verificationStatus: "verified",
+          verified: true,
+        });
+      }
+    );
+
+    it(
+      "does not regress verification failure while failed step evidence remains",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [
+              verified,
+              {
+                ...confirmed,
+                verified: false,
+              },
+            ],
+            "verification-failed",
+            "failed"
+          )
+        ).toEqual({
+          status: "verification-failed",
+          verificationStatus: "failed",
+          verified: false,
+        });
+      }
+    );
+
+    it(
+      "retains verification failure after submission and confirmation",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [
+              verified,
+              {
+                ...confirmed,
+                verified: false,
+              },
+            ],
+            "confirmed",
+            "pending"
+          )
+        ).toEqual({
+          status: "verification-failed",
+          verificationStatus: "failed",
+          verified: false,
+        });
+      }
+    );
+  }
+);
diff --git a/backend/src/shared/services/transaction-evidence.service.ts b/backend/src/shared/services/transaction-evidence.service.ts
index f83af4f..22a7140 100644
--- a/backend/src/shared/services/transaction-evidence.service.ts
+++ b/backend/src/shared/services/transaction-evidence.service.ts
@@ -430,6 +430,135 @@ interface StoredEvidenceStep {
   updatedAt?: string;
 }
 
+export type EvidenceLifecycleStatus =
+  | "intent-recorded"
+  | "prepared"
+  | "partially-submitted"
+  | "submitted"
+  | "partially-confirmed"
+  | "confirmed"
+  | "verified"
+  | "verification-failed";
+
+export interface EvidenceLifecycleStepState {
+  txHash?: string | null;
+  receiptStatus?: number | null;
+  receiptRetrievedAt?: string | null;
+  verified?: boolean | null;
+}
+
+export interface EvidenceLifecycleRollup {
+  status: string;
+  verificationStatus: string | null;
+  verified: boolean;
+}
+
+export function deriveEvidenceLifecycleStatus(
+  steps: EvidenceLifecycleStepState[],
+  currentStatus: string,
+  currentVerificationStatus: string | null = null
+): EvidenceLifecycleRollup {
+  if (steps.length === 0) {
+    return {
+      status: currentStatus,
+      verificationStatus:
+        currentVerificationStatus,
+      verified: currentStatus === "verified",
+    };
+  }
+
+  const isSubmitted = (
+    step: EvidenceLifecycleStepState
+  ) => Boolean(step.txHash);
+
+  const isConfirmed = (
+    step: EvidenceLifecycleStepState
+  ) =>
+    isSubmitted(step) &&
+    Boolean(step.receiptRetrievedAt) &&
+    step.receiptStatus !== null &&
+    step.receiptStatus !== undefined;
+
+  const anySubmitted =
+    steps.some(isSubmitted);
+
+  const allSubmitted =
+    steps.every(isSubmitted);
+
+  const anyConfirmed =
+    steps.some(isConfirmed);
+
+  const allConfirmed =
+    steps.every(isConfirmed);
+
+  const anyVerificationFailed =
+    steps.some(
+      (step) =>
+        isSubmitted(step) &&
+        step.verified === false
+    );
+
+  const allVerified =
+    steps.every(
+      (step) => step.verified === true
+    );
+
+  if (allVerified) {
+    return {
+      status: "verified",
+      verificationStatus: "verified",
+      verified: true,
+    };
+  }
+
+  if (anyVerificationFailed) {
+    return {
+      status: "verification-failed",
+      verificationStatus: "failed",
+      verified: false,
+    };
+  }
+
+  if (allConfirmed) {
+    return {
+      status: "confirmed",
+      verificationStatus: "pending",
+      verified: false,
+    };
+  }
+
+  if (anyConfirmed) {
+    return {
+      status: "partially-confirmed",
+      verificationStatus: "pending",
+      verified: false,
+    };
+  }
+
+  if (allSubmitted) {
+    return {
+      status: "submitted",
+      verificationStatus: "pending",
+      verified: false,
+    };
+  }
+
+  if (anySubmitted) {
+    return {
+      status: "partially-submitted",
+      verificationStatus: "pending",
+      verified: false,
+    };
+  }
+
+  return {
+    status: currentStatus,
+    verificationStatus:
+      currentVerificationStatus,
+    verified: currentStatus === "verified",
+  };
+}
+
 export interface SubmitEvidenceInput {
   correlationId: string;
   stepIndex: number;
@@ -692,21 +821,38 @@ export async function submitAndVerifyEvidence(
     throw new Error("Evidence step mismatch");
   }
 
-  const submittedAt = new Date().toISOString();
+  const normalisedTxHash = input.txHash.toLowerCase();
+
+  if (
+    step.txHash &&
+    step.txHash.toLowerCase() !== normalisedTxHash
+  ) {
+    throw new Error(
+      `Evidence step ${input.stepIndex} already has a different transaction hash`
+    );
+  }
+
+  const submittedAt =
+    step.submittedAt || new Date().toISOString();
 
   await gatewayPatch(
     "transaction-evidence-steps",
     step.id,
     {
-      txHash: input.txHash.toLowerCase(),
+      txHash: normalisedTxHash,
       submittedAt,
       executionMechanism:
         input.executionMechanism || "thirdweb-client",
       providerMetadata: input.providerMetadata || {},
     },
-    `phase2-submission:${input.correlationId}:${input.stepIndex}:${input.txHash.toLowerCase()}`
+    `phase2-submission:${input.correlationId}:${input.stepIndex}:${normalisedTxHash}`
   );
 
+  // The transaction hash is durable evidence in its own right.
+  // Roll up immediately so an RPC/verification failure can never
+  // leave the parent record looking merely "prepared".
+  await rollUpEvidenceStatus(input.correlationId);
+
   const { getProvider } = await import("../../providers/chain.provider");
 
   const provider = getProvider(input.chain);
@@ -801,6 +947,9 @@ export async function submitAndVerifyEvidence(
     },
   };
 
+  // Persist independent receipt confirmation separately from
+  // verification. This makes confirmation a durable lifecycle
+  // checkpoint even if verification fails or the process stops.
   await gatewayPatch(
     "transaction-evidence-steps",
     step.id,
@@ -822,12 +971,23 @@ export async function submitAndVerifyEvidence(
       logsHash: receiptEvidence.logsHash,
       receipt: receiptEvidence,
       receiptRetrievedAt: retrievedAt,
+    },
+    `phase2-receipt:${input.correlationId}:${input.stepIndex}:${input.txHash.toLowerCase()}`
+  );
+
+  await rollUpEvidenceStatus(input.correlationId);
+
+  await gatewayPatch(
+    "transaction-evidence-steps",
+    step.id,
+    {
       verified,
       receiptMatchesSubmission,
       senderMatchesExpected,
       destinationMatchesExpected,
       chainMatchesExpected,
-      verificationSource: `execution-layer-rpc:${input.chain}`,
+      verificationSource:
+        `execution-layer-rpc:${input.chain}`,
       verification,
       verificationError: verified
         ? undefined
@@ -940,48 +1100,27 @@ async function rollUpEvidenceStatus(
   const { evidence, steps } =
     await getTransactionEvidence(correlationId);
 
-  const allVerified =
-    steps.length > 0 &&
-    steps.every((step) => step.verified === true);
-
-  const anyFailed =
-    steps.some(
-      (step) =>
-        step.txHash &&
-        step.verified === false
+  const lifecycle =
+    deriveEvidenceLifecycleStatus(
+      steps,
+      evidence.status,
+      evidence.verificationStatus || null
     );
 
-  const anySubmitted =
-    steps.some((step) => Boolean(step.txHash));
-
   const now = new Date().toISOString();
 
-  let status = evidence.status;
-  let verificationStatus =
-    evidence.verificationStatus || null;
-  let verifiedAt: string | undefined;
-
-  if (allVerified) {
-    status = "verified";
-    verificationStatus = "verified";
-    verifiedAt = now;
-  } else if (anyFailed) {
-    status = "verification-failed";
-    verificationStatus = "failed";
-  } else if (anySubmitted) {
-    status = "partially-verified";
-    verificationStatus = "pending";
-  }
-
   await gatewayPatch(
     "transaction-evidence",
     correlationId,
     {
-      status,
-      verificationStatus,
-      ...(verifiedAt ? { verifiedAt } : {}),
+      status: lifecycle.status,
+      verificationStatus:
+        lifecycle.verificationStatus,
+      ...(lifecycle.verified
+        ? { verifiedAt: evidence.verifiedAt || now }
+        : {}),
     },
-    `phase2-rollup:${correlationId}:${status}`
+    `phase2-rollup:${correlationId}:${lifecycle.status}`
   );
 }
 

From 5f53284ab452e37aa634c8b3cb424f25ecdc8f28 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= 
Date: Mon, 31 Aug 2026 10:46:20 +0200
Subject: [PATCH 15/18] feat: add independent evidence verification

---
 .../lending-evidence-submission.test.ts       |  48 ++
 .../controllers/avax-lending.controller.ts    |  61 ++
 .../routes/avax-lending.routes.ts             |  16 +
 .../controllers/avax-swap.controller.ts       |  61 ++
 .../avax-swap/routes/avax-swap.routes.ts      |  16 +
 ...-evidence-independent-verification.test.ts | 680 ++++++++++++++++++
 .../services/transaction-evidence.service.ts  | 112 ++-
 7 files changed, 981 insertions(+), 13 deletions(-)
 create mode 100644 backend/src/shared/services/__tests__/transaction-evidence-independent-verification.test.ts

diff --git a/backend/src/__tests__/modules/avax-lending/lending-evidence-submission.test.ts b/backend/src/__tests__/modules/avax-lending/lending-evidence-submission.test.ts
index 8faefd7..6c0da03 100644
--- a/backend/src/__tests__/modules/avax-lending/lending-evidence-submission.test.ts
+++ b/backend/src/__tests__/modules/avax-lending/lending-evidence-submission.test.ts
@@ -2,19 +2,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
 
 const {
   mockSubmitAndVerifyEvidence,
+  mockVerifyEvidenceStep,
 } = vi.hoisted(() => ({
   mockSubmitAndVerifyEvidence: vi.fn(),
+  mockVerifyEvidenceStep: vi.fn(),
 }));
 
 vi.mock(
   "../../../shared/services/transaction-evidence.service",
   () => ({
     submitAndVerifyEvidence: mockSubmitAndVerifyEvidence,
+    verifyEvidenceStep: mockVerifyEvidenceStep,
   })
 );
 
 import {
   submitEvidence,
+  verifyEvidence,
 } from "../../../modules/avax-lending/controllers/avax-lending.controller";
 
 function invokeHandler(
@@ -57,6 +61,14 @@ describe("Avalanche lending evidence submission", () => {
         "0x1111111111111111111111111111111111111111111111111111111111111111",
       verified: true,
     });
+
+    mockVerifyEvidenceStep.mockResolvedValue({
+      correlationId: "corr-123",
+      stepIndex: 0,
+      txHash:
+        "0x1111111111111111111111111111111111111111111111111111111111111111",
+      verified: true,
+    });
   });
 
   it("submits lending evidence through the generic Avalanche verifier", async () => {
@@ -104,4 +116,40 @@ describe("Avalanche lending evidence submission", () => {
       verified: true,
     });
   });
+  it(
+    "re-verifies persisted evidence without accepting a transaction hash",
+    async () => {
+      const { json } = await invokeHandler(
+        verifyEvidence,
+        {
+          params: {
+            correlationId: "corr-123",
+          },
+          body: {
+            stepIndex: 0,
+          },
+        }
+      );
+
+      expect(
+        mockVerifyEvidenceStep
+      ).toHaveBeenCalledTimes(1);
+
+      expect(
+        mockVerifyEvidenceStep
+      ).toHaveBeenCalledWith({
+        correlationId: "corr-123",
+        stepIndex: 0,
+      });
+
+      expect(json).toHaveBeenCalledWith({
+        correlationId: "corr-123",
+        stepIndex: 0,
+        txHash:
+          "0x1111111111111111111111111111111111111111111111111111111111111111",
+        verified: true,
+      });
+    }
+  );
+
 });
diff --git a/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts b/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts
index 3b23f2c..3b1d6fe 100644
--- a/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts
+++ b/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts
@@ -10,6 +10,7 @@ import { getContract }              from "../../../providers/chain.provider";
 import { BENQI_TOKEN_ABI }         from "../../../utils/abi";
 import {
   submitAndVerifyEvidence,
+  verifyEvidenceStep,
 } from "../../../shared/services/transaction-evidence.service";
 import { AppError } from "../../../shared/errorCodes";
 
@@ -130,6 +131,66 @@ export const submitEvidence = asyncHandler(async (req: Request, res: Response) =
   }
 });
 
+
+export const verifyEvidence = asyncHandler(
+  async (req: Request, res: Response) => {
+    const { correlationId } = req.params;
+    const { stepIndex } = req.body;
+
+    if (
+      !correlationId ||
+      typeof correlationId !== "string"
+    ) {
+      throw new AppError(
+        "MISSING_FIELD",
+        "correlationId is required"
+      );
+    }
+
+    if (
+      !Number.isInteger(stepIndex) ||
+      stepIndex < 0
+    ) {
+      throw new AppError(
+        "MISSING_FIELD",
+        "stepIndex must be a non-negative integer"
+      );
+    }
+
+    try {
+      const result =
+        await verifyEvidenceStep({
+          correlationId,
+          stepIndex,
+        });
+
+      res.json(result);
+    } catch (err) {
+      if (err instanceof AppError) {
+        throw err;
+      }
+
+      const message =
+        err instanceof Error
+          ? err.message
+          : "Evidence verification failed";
+
+      if (message.includes("not found")) {
+        throw new AppError(
+          "TRANSACTION_NOT_FOUND",
+          message
+        );
+      }
+
+      throw new AppError(
+        "INTERNAL_ERROR",
+        message
+      );
+    }
+  }
+);
+
+
 export const prepareRedeem = asyncHandler(async (req: Request, res: Response) => {
   const result = await executePrepareRedeem({
     userAddress:   req.body.userAddress,
diff --git a/backend/src/modules/avax-lending/routes/avax-lending.routes.ts b/backend/src/modules/avax-lending/routes/avax-lending.routes.ts
index 11370b7..a38c940 100644
--- a/backend/src/modules/avax-lending/routes/avax-lending.routes.ts
+++ b/backend/src/modules/avax-lending/routes/avax-lending.routes.ts
@@ -50,6 +50,22 @@ avaxLendingRoutes.post(
   ctrl.submitEvidence
 );
 
+/**
+ * POST /avax/lending/evidence/:correlationId/verifications
+ * Independently re-verifies the persisted submitted transaction.
+ *
+ * Body: { stepIndex }
+ *
+ * txHash is deliberately not accepted. Verification is bound to
+ * the immutable transaction hash already persisted for the step.
+ */
+avaxLendingRoutes.post(
+  "/evidence/:correlationId/verifications",
+  validateRequired("stepIndex"),
+  executionTimeout(),
+  ctrl.verifyEvidence
+);
+
 /**
  * POST /avax/lending/prepare-redeem
  * Prepares a TransactionBundle to redeem qTokens for underlying.
diff --git a/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts b/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
index a94ff1e..733b75c 100644
--- a/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
+++ b/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
@@ -5,6 +5,7 @@ import { executePrepareAvaxSwap } from "../usecases/prepare-swap.usecase";
 import { getEnabledSwapPairs }    from "../config/avax-swap-pairs";
 import {
   submitAndVerifyEvidence,
+  verifyEvidenceStep,
   getTransactionEvidence,
   exportTransactionEvidence,
   exportTransactionEvidenceByWallet,
@@ -169,6 +170,66 @@ export const submitEvidence = asyncHandler(async (req: Request, res: Response) =
   }
 });
 
+
+export const verifyEvidence = asyncHandler(
+  async (req: Request, res: Response) => {
+    const { correlationId } = req.params;
+    const { stepIndex } = req.body;
+
+    if (
+      !correlationId ||
+      typeof correlationId !== "string"
+    ) {
+      throw new AppError(
+        "MISSING_FIELD",
+        "correlationId is required"
+      );
+    }
+
+    if (
+      !Number.isInteger(stepIndex) ||
+      stepIndex < 0
+    ) {
+      throw new AppError(
+        "MISSING_FIELD",
+        "stepIndex must be a non-negative integer"
+      );
+    }
+
+    try {
+      const result =
+        await verifyEvidenceStep({
+          correlationId,
+          stepIndex,
+        });
+
+      res.json(result);
+    } catch (err) {
+      if (err instanceof AppError) {
+        throw err;
+      }
+
+      const message =
+        err instanceof Error
+          ? err.message
+          : "Evidence verification failed";
+
+      if (message.includes("not found")) {
+        throw new AppError(
+          "TRANSACTION_NOT_FOUND",
+          message
+        );
+      }
+
+      throw new AppError(
+        "INTERNAL_ERROR",
+        message
+      );
+    }
+  }
+);
+
+
 export const getEvidence = asyncHandler(
   async (req: Request, res: Response) => {
     const { correlationId } = req.params;
diff --git a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
index 11ec993..395324c 100644
--- a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
+++ b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
@@ -157,6 +157,22 @@ avaxSwapRoutes.post(
   ctrl.submitEvidence
 );
 
+/**
+ * POST /avax/swap/evidence/:correlationId/verifications
+ * Independently re-verifies the persisted submitted transaction.
+ *
+ * Body: { stepIndex }
+ *
+ * txHash is deliberately not accepted. Verification is bound to
+ * the immutable transaction hash already persisted for the step.
+ */
+avaxSwapRoutes.post(
+  "/evidence/:correlationId/verifications",
+  validateRequired("stepIndex"),
+  executionTimeout(),
+  ctrl.verifyEvidence
+);
+
 /**
  * GET /avax/swap/evidence/export/:userAddress
  * Returns a wallet-scoped bulk evidence export.
diff --git a/backend/src/shared/services/__tests__/transaction-evidence-independent-verification.test.ts b/backend/src/shared/services/__tests__/transaction-evidence-independent-verification.test.ts
new file mode 100644
index 0000000..6a7ba77
--- /dev/null
+++ b/backend/src/shared/services/__tests__/transaction-evidence-independent-verification.test.ts
@@ -0,0 +1,680 @@
+import {
+  afterEach,
+  beforeEach,
+  describe,
+  expect,
+  it,
+  vi,
+} from "vitest";
+import { ethers } from "ethers";
+
+const {
+  mockGetProvider,
+  mockGetNetwork,
+  mockGetTransaction,
+  mockGetTransactionReceipt,
+} = vi.hoisted(() => ({
+  mockGetProvider: vi.fn(),
+  mockGetNetwork: vi.fn(),
+  mockGetTransaction: vi.fn(),
+  mockGetTransactionReceipt: vi.fn(),
+}));
+
+vi.mock(
+  "../../../providers/chain.provider",
+  () => ({
+    getProvider: mockGetProvider,
+  })
+);
+
+import {
+  resolveEvidenceChain,
+  verifyEvidenceStep,
+} from "../transaction-evidence.service";
+
+const CORRELATION_ID = "corr-e7";
+const STEP_INDEX = 0;
+
+const WALLET =
+  "0x1111111111111111111111111111111111111111";
+
+const DESTINATION =
+  "0x2222222222222222222222222222222222222222";
+
+const OTHER_ADDRESS =
+  "0x3333333333333333333333333333333333333333";
+
+const TX_HASH =
+  "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+const OTHER_TX_HASH =
+  "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+
+const BLOCK_HASH =
+  "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
+
+const DATA = "0x1234";
+const DATA_HASH = ethers.keccak256(DATA);
+
+const response = (data: unknown) =>
+  Promise.resolve({
+    ok: true,
+    status: 200,
+    text: async () =>
+      JSON.stringify(data),
+  } as Response);
+
+function makeParent(
+  overrides: Record = {}
+) {
+  return {
+    correlationId: CORRELATION_ID,
+    evidenceVersion: "1.0",
+    action: "test",
+    chainId: 43114,
+    network: "avalanche",
+    walletAddress: WALLET,
+    intent: {},
+    preparedPayloadHash: "0xprepared",
+    preparedAt:
+      "2026-08-31T08:00:00.000Z",
+    preparedMetadata: {
+      totalSteps: 1,
+    },
+    status: "submitted",
+    verificationStatus: "pending",
+    createdAt:
+      "2026-08-31T08:00:00.000Z",
+    updatedAt:
+      "2026-08-31T08:01:00.000Z",
+    ...overrides,
+  };
+}
+
+function makeStep(
+  overrides: Record = {}
+) {
+  return {
+    id: `${CORRELATION_ID}:${STEP_INDEX}`,
+    correlationId: CORRELATION_ID,
+    stepIndex: STEP_INDEX,
+    action: "swap",
+    chainId: 43114,
+    toAddress: DESTINATION,
+    value: "0",
+    dataHash: DATA_HASH,
+    preparedStepHash:
+      "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+    preparedAt:
+      "2026-08-31T08:00:00.000Z",
+    txHash: TX_HASH,
+    submittedAt:
+      "2026-08-31T08:00:30.000Z",
+    ...overrides,
+  };
+}
+
+function makeTransaction(
+  overrides: Record = {}
+) {
+  return {
+    hash: TX_HASH,
+    from: WALLET,
+    to: DESTINATION,
+    data: DATA,
+    value: 0n,
+    ...overrides,
+  };
+}
+
+function makeReceipt(
+  overrides: Record = {}
+) {
+  return {
+    hash: TX_HASH,
+    blockNumber: 123456,
+    blockHash: BLOCK_HASH,
+    status: 1,
+    from: WALLET,
+    to: DESTINATION,
+    contractAddress: null,
+    gasUsed: 21000n,
+    gasPrice: 25000000000n,
+    logs: [],
+    ...overrides,
+  };
+}
+
+function installGateway(
+  parent = makeParent(),
+  step = makeStep()
+) {
+  const fetchMock = vi.fn(
+    async (
+      input: string | URL | Request,
+      init?: RequestInit
+    ) => {
+      const url = String(input);
+      const method =
+        init?.method || "GET";
+
+      if (method === "GET") {
+        if (
+          url.endsWith(
+            `/v1/transaction-evidence/${CORRELATION_ID}`
+          )
+        ) {
+          return response({
+            data: parent,
+          });
+        }
+
+        if (
+          url.endsWith(
+            `/v1/transaction-evidence-steps/${CORRELATION_ID}%3A${STEP_INDEX}`
+          )
+        ) {
+          return response({
+            data: step,
+          });
+        }
+      }
+
+      if (method === "PATCH") {
+        return response({
+          data: {},
+        });
+      }
+
+      throw new Error(
+        `Unexpected request: ${method} ${url}`
+      );
+    }
+  );
+
+  vi.stubGlobal(
+    "fetch",
+    fetchMock
+  );
+
+  return fetchMock;
+}
+
+function patchKeys(
+  fetchMock: ReturnType
+): string[] {
+  return fetchMock.mock.calls
+    .filter(
+      ([, init]) =>
+        (init as RequestInit | undefined)
+          ?.method === "PATCH"
+    )
+    .map(([, init]) => {
+      const headers =
+        (init as RequestInit).headers as
+          Record;
+
+      return headers[
+        "Idempotency-Key"
+      ];
+    });
+}
+
+describe(
+  "independent transaction evidence verification",
+  () => {
+    beforeEach(() => {
+      process.env.PHASE2_EVIDENCE_ENABLED =
+        "true";
+      process.env.DB_GATEWAY_URL =
+        "http://gateway.test";
+      process.env.DB_GATEWAY_SERVICE_TOKEN =
+        "test-token";
+      process.env.DB_GATEWAY_TENANT_ID =
+        "panorama-test";
+
+      mockGetNetwork.mockResolvedValue({
+        chainId: 43114n,
+      });
+
+      mockGetTransaction.mockResolvedValue(
+        makeTransaction()
+      );
+
+      mockGetTransactionReceipt.mockResolvedValue(
+        makeReceipt()
+      );
+
+      mockGetProvider.mockReturnValue({
+        getNetwork: mockGetNetwork,
+        getTransaction:
+          mockGetTransaction,
+        getTransactionReceipt:
+          mockGetTransactionReceipt,
+      });
+    });
+
+    afterEach(() => {
+      vi.unstubAllGlobals();
+      vi.clearAllMocks();
+    });
+
+    it(
+      "derives supported chains from persisted chain ids",
+      () => {
+        expect(
+          resolveEvidenceChain(43114)
+        ).toBe("avalanche");
+
+        expect(
+          resolveEvidenceChain(8453)
+        ).toBe("base");
+      }
+    );
+
+    it(
+      "refuses an unsupported persisted chain id",
+      () => {
+        expect(
+          () =>
+            resolveEvidenceChain(1)
+        ).toThrow(
+          "Unsupported evidence chain id: 1"
+        );
+      }
+    );
+
+    it(
+      "uses the persisted step hash and chain for independent RPC verification",
+      async () => {
+        installGateway();
+
+        const result =
+          await verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          });
+
+        expect(
+          mockGetProvider
+        ).toHaveBeenCalledWith(
+          "avalanche"
+        );
+
+        expect(
+          mockGetTransaction
+        ).toHaveBeenCalledWith(
+          TX_HASH
+        );
+
+        expect(
+          mockGetTransactionReceipt
+        ).toHaveBeenCalledWith(
+          TX_HASH
+        );
+
+        expect(result.verified).toBe(
+          true
+        );
+
+        expect(result.txHash).toBe(
+          TX_HASH
+        );
+      }
+    );
+
+    it(
+      "accepts a participating Avalanche step even when the parent intent chain is different",
+      async () => {
+        installGateway(
+          makeParent({
+            chainId: 8453,
+            network: "base",
+          }),
+          makeStep({
+            chainId: 43114,
+          })
+        );
+
+        const result =
+          await verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          });
+
+        expect(
+          mockGetProvider
+        ).toHaveBeenCalledWith(
+          "avalanche"
+        );
+
+        expect(
+          result.chainMatchesExpected
+        ).toBe(true);
+
+        expect(result.verified).toBe(
+          true
+        );
+      }
+    );
+
+    it(
+      "rejects verification before RPC when no submitted transaction hash is persisted",
+      async () => {
+        installGateway(
+          makeParent(),
+          makeStep({
+            txHash: null,
+          })
+        );
+
+        await expect(
+          verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          })
+        ).rejects.toThrow(
+          "Submitted transaction hash not found"
+        );
+
+        expect(
+          mockGetProvider
+        ).not.toHaveBeenCalled();
+      }
+    );
+
+    it(
+      "rejects when the persisted transaction cannot be retrieved",
+      async () => {
+        installGateway();
+
+        mockGetTransaction.mockResolvedValue(
+          null
+        );
+
+        await expect(
+          verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          })
+        ).rejects.toThrow(
+          `Transaction ${TX_HASH} not found via independent RPC`
+        );
+      }
+    );
+
+    it(
+      "rejects when the persisted transaction receipt cannot be retrieved",
+      async () => {
+        installGateway();
+
+        mockGetTransactionReceipt
+          .mockResolvedValue(null);
+
+        await expect(
+          verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          })
+        ).rejects.toThrow(
+          `Receipt ${TX_HASH} not found via independent RPC`
+        );
+      }
+    );
+
+    it(
+      "fails verification when the sender does not match the committed wallet",
+      async () => {
+        installGateway();
+
+        mockGetTransaction
+          .mockResolvedValue(
+            makeTransaction({
+              from: OTHER_ADDRESS,
+            })
+          );
+
+        const result =
+          await verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          });
+
+        expect(
+          result.senderMatchesExpected
+        ).toBe(false);
+
+        expect(result.verified).toBe(
+          false
+        );
+      }
+    );
+
+    it(
+      "fails verification when the destination does not match the prepared step",
+      async () => {
+        installGateway();
+
+        mockGetTransaction
+          .mockResolvedValue(
+            makeTransaction({
+              to: OTHER_ADDRESS,
+            })
+          );
+
+        const result =
+          await verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          });
+
+        expect(
+          result.destinationMatchesExpected
+        ).toBe(false);
+
+        expect(result.verified).toBe(
+          false
+        );
+      }
+    );
+
+    it(
+      "fails verification when calldata does not match the prepared commitment",
+      async () => {
+        installGateway();
+
+        mockGetTransaction
+          .mockResolvedValue(
+            makeTransaction({
+              data: "0x5678",
+            })
+          );
+
+        const result =
+          await verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          });
+
+        expect(
+          result.dataMatchesExpected
+        ).toBe(false);
+
+        expect(result.verified).toBe(
+          false
+        );
+      }
+    );
+
+    it(
+      "fails verification when value does not match the prepared commitment",
+      async () => {
+        installGateway();
+
+        mockGetTransaction
+          .mockResolvedValue(
+            makeTransaction({
+              value: 1n,
+            })
+          );
+
+        const result =
+          await verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          });
+
+        expect(
+          result.valueMatchesExpected
+        ).toBe(false);
+
+        expect(result.verified).toBe(
+          false
+        );
+      }
+    );
+
+    it(
+      "fails verification when RPC network does not match the persisted step chain",
+      async () => {
+        installGateway();
+
+        mockGetNetwork.mockResolvedValue({
+          chainId: 8453n,
+        });
+
+        const result =
+          await verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          });
+
+        expect(
+          result.chainMatchesExpected
+        ).toBe(false);
+
+        expect(result.verified).toBe(
+          false
+        );
+      }
+    );
+
+    it(
+      "fails verification when the retrieved receipt hash is not the persisted submission hash",
+      async () => {
+        installGateway();
+
+        mockGetTransactionReceipt
+          .mockResolvedValue(
+            makeReceipt({
+              hash: OTHER_TX_HASH,
+            })
+          );
+
+        const result =
+          await verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          });
+
+        expect(result.verified).toBe(
+          false
+        );
+      }
+    );
+
+    it(
+      "persists receipt confirmation before verification outcome",
+      async () => {
+        const fetchMock =
+          installGateway();
+
+        await verifyEvidenceStep({
+          correlationId:
+            CORRELATION_ID,
+          stepIndex: STEP_INDEX,
+        });
+
+        const keys =
+          patchKeys(fetchMock);
+
+        const receiptIndex =
+          keys.findIndex(
+            (key) =>
+              key.startsWith(
+                "phase2-receipt:"
+              )
+          );
+
+        const verificationIndex =
+          keys.findIndex(
+            (key) =>
+              key.startsWith(
+                "phase2-verification:"
+              )
+          );
+
+        expect(
+          receiptIndex
+        ).toBeGreaterThanOrEqual(0);
+
+        expect(
+          verificationIndex
+        ).toBeGreaterThan(
+          receiptIndex
+        );
+      }
+    );
+
+    it(
+      "can re-verify persisted evidence without creating a new submission",
+      async () => {
+        const fetchMock =
+          installGateway();
+
+        await verifyEvidenceStep({
+          correlationId:
+            CORRELATION_ID,
+          stepIndex: STEP_INDEX,
+        });
+
+        await verifyEvidenceStep({
+          correlationId:
+            CORRELATION_ID,
+          stepIndex: STEP_INDEX,
+        });
+
+        const keys =
+          patchKeys(fetchMock);
+
+        expect(
+          keys.some(
+            (key) =>
+              key.startsWith(
+                "phase2-submission:"
+              )
+          )
+        ).toBe(false);
+
+        expect(
+          mockGetTransaction
+        ).toHaveBeenCalledTimes(2);
+
+        expect(
+          mockGetTransactionReceipt
+        ).toHaveBeenCalledTimes(2);
+      }
+    );
+  }
+);
diff --git a/backend/src/shared/services/transaction-evidence.service.ts b/backend/src/shared/services/transaction-evidence.service.ts
index 22a7140..0f10d92 100644
--- a/backend/src/shared/services/transaction-evidence.service.ts
+++ b/backend/src/shared/services/transaction-evidence.service.ts
@@ -583,6 +583,27 @@ export interface VerificationResult {
   valueMatchesExpected: boolean;
 }
 
+export interface VerifyEvidenceInput {
+  correlationId: string;
+  stepIndex: number;
+}
+
+export function resolveEvidenceChain(
+  chainId: number
+): "avalanche" | "base" {
+  if (chainId === 43114) {
+    return "avalanche";
+  }
+
+  if (chainId === 8453) {
+    return "base";
+  }
+
+  throw new Error(
+    `Unsupported evidence chain id: ${chainId}`
+  );
+}
+
 async function gatewayGet(path: string): Promise {
   const config = gatewayConfig();
   const controller = new AbortController();
@@ -853,22 +874,88 @@ export async function submitAndVerifyEvidence(
   // leave the parent record looking merely "prepared".
   await rollUpEvidenceStatus(input.correlationId);
 
+  return verifyEvidenceStep({
+    correlationId: input.correlationId,
+    stepIndex: input.stepIndex,
+  });
+}
+
+export async function verifyEvidenceStep(
+  input: VerifyEvidenceInput
+): Promise {
+  if (!evidenceEnabled()) {
+    throw new Error(
+      "Transaction evidence verification requires PHASE2_EVIDENCE_ENABLED=true"
+    );
+  }
+
+  if (
+    !Number.isInteger(input.stepIndex) ||
+    input.stepIndex < 0
+  ) {
+    throw new Error("Invalid step index");
+  }
+
+  const parent = await gatewayGet(
+    `/v1/transaction-evidence/${encodeURIComponent(input.correlationId)}`
+  );
+
+  const stepId =
+    `${input.correlationId}:${input.stepIndex}`;
+
+  const step = await gatewayGet(
+    `/v1/transaction-evidence-steps/${encodeURIComponent(stepId)}`
+  );
+
+  if (!parent || !step) {
+    throw new Error(
+      "Evidence record or prepared step not found"
+    );
+  }
+
+  if (
+    step.correlationId !== input.correlationId
+  ) {
+    throw new Error(
+      "Evidence correlation mismatch"
+    );
+  }
+
+  if (step.stepIndex !== input.stepIndex) {
+    throw new Error(
+      "Evidence step mismatch"
+    );
+  }
+
+  if (
+    !step.txHash ||
+    !/^0x[a-fA-F0-9]{64}$/.test(step.txHash)
+  ) {
+    throw new Error(
+      `Submitted transaction hash not found for evidence step ${input.stepIndex}`
+    );
+  }
+
+  const txHash = step.txHash.toLowerCase();
+  const chain =
+    resolveEvidenceChain(step.chainId);
+
   const { getProvider } = await import("../../providers/chain.provider");
 
-  const provider = getProvider(input.chain);
+  const provider = getProvider(chain);
 
   const [network, tx, receipt] = await Promise.all([
     provider.getNetwork(),
-    provider.getTransaction(input.txHash),
-    provider.getTransactionReceipt(input.txHash),
+    provider.getTransaction(txHash),
+    provider.getTransactionReceipt(txHash),
   ]);
 
   if (!tx) {
-    throw new Error(`Transaction ${input.txHash} not found via independent RPC`);
+    throw new Error(`Transaction ${txHash} not found via independent RPC`);
   }
 
   if (!receipt) {
-    throw new Error(`Receipt ${input.txHash} not found via independent RPC`);
+    throw new Error(`Receipt ${txHash} not found via independent RPC`);
   }
 
   const actualChainId = Number(network.chainId);
@@ -881,8 +968,7 @@ export async function submitAndVerifyEvidence(
     (tx.to || "").toLowerCase() === step.toAddress.toLowerCase();
 
   const chainMatchesExpected =
-    actualChainId === expectedChainId &&
-    parent.chainId === expectedChainId;
+    actualChainId === expectedChainId;
 
   const actualDataHash = ethers.keccak256(tx.data || "0x");
   const dataMatchesExpected =
@@ -892,7 +978,7 @@ export async function submitAndVerifyEvidence(
     tx.value.toString() === step.value;
 
   const receiptMatchesSubmission =
-    receipt.hash.toLowerCase() === input.txHash.toLowerCase();
+    receipt.hash.toLowerCase() === txHash.toLowerCase();
 
   const verified =
     receiptMatchesSubmission &&
@@ -972,7 +1058,7 @@ export async function submitAndVerifyEvidence(
       receipt: receiptEvidence,
       receiptRetrievedAt: retrievedAt,
     },
-    `phase2-receipt:${input.correlationId}:${input.stepIndex}:${input.txHash.toLowerCase()}`
+    `phase2-receipt:${input.correlationId}:${input.stepIndex}:${txHash.toLowerCase()}`
   );
 
   await rollUpEvidenceStatus(input.correlationId);
@@ -987,14 +1073,14 @@ export async function submitAndVerifyEvidence(
       destinationMatchesExpected,
       chainMatchesExpected,
       verificationSource:
-        `execution-layer-rpc:${input.chain}`,
+        `execution-layer-rpc:${chain}`,
       verification,
       verificationError: verified
         ? undefined
         : "Independent receipt verification mismatch",
       verifiedAt: retrievedAt,
     },
-    `phase2-verification:${input.correlationId}:${input.stepIndex}:${input.txHash.toLowerCase()}`
+    `phase2-verification:${input.correlationId}:${input.stepIndex}:${txHash.toLowerCase()}`
   );
 
   await rollUpEvidenceStatus(input.correlationId);
@@ -1003,7 +1089,7 @@ export async function submitAndVerifyEvidence(
     {
       correlationId: input.correlationId,
       stepIndex: input.stepIndex,
-      txHash: input.txHash,
+      txHash: txHash,
       chainId: actualChainId,
       verified,
     },
@@ -1013,7 +1099,7 @@ export async function submitAndVerifyEvidence(
   return {
     correlationId: input.correlationId,
     stepIndex: input.stepIndex,
-    txHash: input.txHash.toLowerCase(),
+    txHash: txHash.toLowerCase(),
     verified,
     receiptStatus: receipt.status,
     blockNumber: String(receipt.blockNumber),

From 73161cfc720c58d0f25e65bd43fb1462c3f21187 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= 
Date: Mon, 31 Aug 2026 13:08:17 +0200
Subject: [PATCH 16/18] feat: add evidence failure lifecycle

---
 .../controllers/avax-lending.controller.ts    |  76 +++++
 .../routes/avax-lending.routes.ts             |   7 +
 .../controllers/avax-swap.controller.ts       |  76 +++++
 .../avax-swap/routes/avax-swap.routes.ts      |   7 +
 ...-evidence-independent-verification.test.ts | 121 +++++++-
 .../transaction-evidence-lifecycle.test.ts    | 119 ++++++++
 .../transaction-evidence-outcomes.test.ts     | 281 ++++++++++++++++++
 .../services/transaction-evidence.service.ts  | 131 ++++++++
 8 files changed, 810 insertions(+), 8 deletions(-)
 create mode 100644 backend/src/shared/services/__tests__/transaction-evidence-outcomes.test.ts

diff --git a/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts b/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts
index 3b1d6fe..4994dc6 100644
--- a/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts
+++ b/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts
@@ -11,6 +11,7 @@ import { BENQI_TOKEN_ABI }         from "../../../utils/abi";
 import {
   submitAndVerifyEvidence,
   verifyEvidenceStep,
+  recordEvidenceExecutionOutcome
 } from "../../../shared/services/transaction-evidence.service";
 import { AppError } from "../../../shared/errorCodes";
 
@@ -132,6 +133,81 @@ export const submitEvidence = asyncHandler(async (req: Request, res: Response) =
 });
 
 
+
+export const recordEvidenceOutcome = asyncHandler(
+  async (req: Request, res: Response) => {
+    const { correlationId } = req.params;
+    const { outcome, reason } = req.body;
+
+    if (
+      !correlationId ||
+      typeof correlationId !== "string"
+    ) {
+      throw new AppError(
+        "MISSING_FIELD",
+        "correlationId is required"
+      );
+    }
+
+    if (
+      outcome !== "cancelled-before-submission" &&
+      outcome !== "partially-executed"
+    ) {
+      throw new AppError(
+        "UNSUPPORTED_OPERATION",
+        "Unsupported evidence execution outcome"
+      );
+    }
+
+    try {
+      const result =
+        await recordEvidenceExecutionOutcome({
+          correlationId,
+          outcome,
+          reason:
+            typeof reason === "string"
+              ? reason
+              : undefined,
+        });
+
+      res.json(result);
+    } catch (error) {
+      if (error instanceof AppError) {
+        throw error;
+      }
+
+      const message =
+        error instanceof Error
+          ? error.message
+          : "Evidence outcome reporting failed";
+
+      if (message.includes("not found")) {
+        throw new AppError(
+          "TRANSACTION_NOT_FOUND",
+          message
+        );
+      }
+
+      if (
+        message.includes("Cannot record") ||
+        message.includes(
+          "at least one but not all"
+        )
+      ) {
+        throw new AppError(
+          "UNSUPPORTED_OPERATION",
+          message
+        );
+      }
+
+      throw new AppError(
+        "INTERNAL_ERROR",
+        message
+      );
+    }
+  }
+);
+
 export const verifyEvidence = asyncHandler(
   async (req: Request, res: Response) => {
     const { correlationId } = req.params;
diff --git a/backend/src/modules/avax-lending/routes/avax-lending.routes.ts b/backend/src/modules/avax-lending/routes/avax-lending.routes.ts
index a38c940..dafe652 100644
--- a/backend/src/modules/avax-lending/routes/avax-lending.routes.ts
+++ b/backend/src/modules/avax-lending/routes/avax-lending.routes.ts
@@ -59,6 +59,13 @@ avaxLendingRoutes.post(
  * txHash is deliberately not accepted. Verification is bound to
  * the immutable transaction hash already persisted for the step.
  */
+avaxLendingRoutes.post(
+  "/evidence/:correlationId/outcomes",
+  validateRequired("outcome"),
+  executionTimeout(),
+  ctrl.recordEvidenceOutcome
+);
+
 avaxLendingRoutes.post(
   "/evidence/:correlationId/verifications",
   validateRequired("stepIndex"),
diff --git a/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts b/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
index 733b75c..56372ae 100644
--- a/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
+++ b/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts
@@ -11,6 +11,7 @@ import {
   exportTransactionEvidenceByWallet,
   exportTransactionEvidenceAdmin,
   isPhase2EvidenceAdmin,
+  recordEvidenceExecutionOutcome
 } from "../../../shared/services/transaction-evidence.service";
 import { AppError } from "../../../shared/errorCodes";
 import {
@@ -171,6 +172,81 @@ export const submitEvidence = asyncHandler(async (req: Request, res: Response) =
 });
 
 
+
+export const recordEvidenceOutcome = asyncHandler(
+  async (req: Request, res: Response) => {
+    const { correlationId } = req.params;
+    const { outcome, reason } = req.body;
+
+    if (
+      !correlationId ||
+      typeof correlationId !== "string"
+    ) {
+      throw new AppError(
+        "MISSING_FIELD",
+        "correlationId is required"
+      );
+    }
+
+    if (
+      outcome !== "cancelled-before-submission" &&
+      outcome !== "partially-executed"
+    ) {
+      throw new AppError(
+        "UNSUPPORTED_OPERATION",
+        "Unsupported evidence execution outcome"
+      );
+    }
+
+    try {
+      const result =
+        await recordEvidenceExecutionOutcome({
+          correlationId,
+          outcome,
+          reason:
+            typeof reason === "string"
+              ? reason
+              : undefined,
+        });
+
+      res.json(result);
+    } catch (error) {
+      if (error instanceof AppError) {
+        throw error;
+      }
+
+      const message =
+        error instanceof Error
+          ? error.message
+          : "Evidence outcome reporting failed";
+
+      if (message.includes("not found")) {
+        throw new AppError(
+          "TRANSACTION_NOT_FOUND",
+          message
+        );
+      }
+
+      if (
+        message.includes("Cannot record") ||
+        message.includes(
+          "at least one but not all"
+        )
+      ) {
+        throw new AppError(
+          "UNSUPPORTED_OPERATION",
+          message
+        );
+      }
+
+      throw new AppError(
+        "INTERNAL_ERROR",
+        message
+      );
+    }
+  }
+);
+
 export const verifyEvidence = asyncHandler(
   async (req: Request, res: Response) => {
     const { correlationId } = req.params;
diff --git a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
index 395324c..49d2f1f 100644
--- a/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
+++ b/backend/src/modules/avax-swap/routes/avax-swap.routes.ts
@@ -166,6 +166,13 @@ avaxSwapRoutes.post(
  * txHash is deliberately not accepted. Verification is bound to
  * the immutable transaction hash already persisted for the step.
  */
+avaxSwapRoutes.post(
+  "/evidence/:correlationId/outcomes",
+  validateRequired("outcome"),
+  executionTimeout(),
+  ctrl.recordEvidenceOutcome
+);
+
 avaxSwapRoutes.post(
   "/evidence/:correlationId/verifications",
   validateRequired("stepIndex"),
diff --git a/backend/src/shared/services/__tests__/transaction-evidence-independent-verification.test.ts b/backend/src/shared/services/__tests__/transaction-evidence-independent-verification.test.ts
index 6a7ba77..edcdc40 100644
--- a/backend/src/shared/services/__tests__/transaction-evidence-independent-verification.test.ts
+++ b/backend/src/shared/services/__tests__/transaction-evidence-independent-verification.test.ts
@@ -149,6 +149,14 @@ function installGateway(
   parent = makeParent(),
   step = makeStep()
 ) {
+  const storedParent = {
+    ...parent,
+  };
+
+  const storedStep = {
+    ...step,
+  };
+
   const fetchMock = vi.fn(
     async (
       input: string | URL | Request,
@@ -165,7 +173,7 @@ function installGateway(
           )
         ) {
           return response({
-            data: parent,
+            data: storedParent,
           });
         }
 
@@ -175,15 +183,46 @@ function installGateway(
           )
         ) {
           return response({
-            data: step,
+            data: storedStep,
           });
         }
       }
 
       if (method === "PATCH") {
-        return response({
-          data: {},
-        });
+        const patch =
+          init?.body
+            ? JSON.parse(String(init.body))
+            : {};
+
+        if (
+          url.endsWith(
+            `/v1/transaction-evidence/${CORRELATION_ID}`
+          )
+        ) {
+          Object.assign(
+            storedParent,
+            patch
+          );
+
+          return response({
+            data: storedParent,
+          });
+        }
+
+        if (
+          url.endsWith(
+            `/v1/transaction-evidence-steps/${CORRELATION_ID}%3A${STEP_INDEX}`
+          )
+        ) {
+          Object.assign(
+            storedStep,
+            patch
+          );
+
+          return response({
+            data: storedStep,
+          });
+        }
       }
 
       throw new Error(
@@ -197,7 +236,11 @@ function installGateway(
     fetchMock
   );
 
-  return fetchMock;
+  return {
+    fetchMock,
+    storedParent,
+    storedStep,
+  };
 }
 
 function patchKeys(
@@ -594,10 +637,72 @@ describe(
       }
     );
 
+    it(
+      "persists a reverted receipt and preserves reverted after verification failure roll-up",
+      async () => {
+        const {
+          fetchMock,
+          storedParent,
+          storedStep,
+        } = installGateway();
+
+        mockGetTransactionReceipt
+          .mockResolvedValue(
+            makeReceipt({
+              status: 0,
+            })
+          );
+
+        const result =
+          await verifyEvidenceStep({
+            correlationId:
+              CORRELATION_ID,
+            stepIndex: STEP_INDEX,
+          });
+
+        expect(result).toMatchObject({
+          verified: false,
+          receiptStatus: 0,
+        });
+
+        expect(storedStep).toMatchObject({
+          receiptStatus: 0,
+          verified: false,
+        });
+
+        expect(
+          (
+            storedStep as Record<
+              string,
+              unknown
+            >
+          ).receiptRetrievedAt
+        ).toBeTruthy();
+
+        expect(storedParent).toMatchObject({
+          status: "reverted",
+          verificationStatus: "failed",
+        });
+
+        const parentRollupKeys =
+          patchKeys(fetchMock).filter(
+            (key) =>
+              key.startsWith(
+                `phase2-rollup:${CORRELATION_ID}:`
+              )
+          );
+
+        expect(parentRollupKeys).toEqual([
+          `phase2-rollup:${CORRELATION_ID}:reverted`,
+          `phase2-rollup:${CORRELATION_ID}:reverted`,
+        ]);
+      }
+    );
+
     it(
       "persists receipt confirmation before verification outcome",
       async () => {
-        const fetchMock =
+        const { fetchMock } =
           installGateway();
 
         await verifyEvidenceStep({
@@ -640,7 +745,7 @@ describe(
     it(
       "can re-verify persisted evidence without creating a new submission",
       async () => {
-        const fetchMock =
+        const { fetchMock } =
           installGateway();
 
         await verifyEvidenceStep({
diff --git a/backend/src/shared/services/__tests__/transaction-evidence-lifecycle.test.ts b/backend/src/shared/services/__tests__/transaction-evidence-lifecycle.test.ts
index 06fd9a3..d88e93d 100644
--- a/backend/src/shared/services/__tests__/transaction-evidence-lifecycle.test.ts
+++ b/backend/src/shared/services/__tests__/transaction-evidence-lifecycle.test.ts
@@ -183,5 +183,124 @@ describe(
         });
       }
     );
+
+
+    it(
+      "preserves cancelled-before-submission while no transaction hash exists",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [prepared, prepared],
+            "cancelled-before-submission"
+          )
+        ).toEqual({
+          status: "cancelled-before-submission",
+          verificationStatus: null,
+          verified: false,
+        });
+      }
+    );
+
+    it(
+      "does not allow cancellation status to hide a later durable submission",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [submitted, prepared],
+            "cancelled-before-submission"
+          ).status
+        ).toBe("partially-submitted");
+      }
+    );
+
+    it(
+      "preserves partially-executed after execution terminates with only some durable hashes",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [submitted, prepared],
+            "partially-executed",
+            "pending"
+          )
+        ).toEqual({
+          status: "partially-executed",
+          verificationStatus: "pending",
+          verified: false,
+        });
+      }
+    );
+
+    it(
+      "preserves partially-executed when an earlier submitted step later confirms",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [confirmed, prepared],
+            "partially-executed",
+            "pending"
+          ).status
+        ).toBe("partially-executed");
+      }
+    );
+
+    it(
+      "advances beyond partially-executed when every prepared step is later submitted",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [submitted, submitted],
+            "partially-executed",
+            "pending"
+          ).status
+        ).toBe("submitted");
+      }
+    );
+
+    it(
+      "derives reverted from an independently retrieved reverted receipt",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [
+              {
+                ...confirmed,
+                receiptStatus: 0,
+                verified: false,
+              },
+            ],
+            "verification-failed",
+            "failed"
+          )
+        ).toEqual({
+          status: "reverted",
+          verificationStatus: "failed",
+          verified: false,
+        });
+      }
+    );
+
+    it(
+      "reverted outranks a generic verification failure",
+      () => {
+        expect(
+          deriveEvidenceLifecycleStatus(
+            [
+              {
+                ...confirmed,
+                receiptStatus: 0,
+                verified: false,
+              },
+              {
+                ...confirmed,
+                verified: false,
+              },
+            ],
+            "verification-failed",
+            "failed"
+          ).status
+        ).toBe("reverted");
+      }
+    );
+
   }
 );
diff --git a/backend/src/shared/services/__tests__/transaction-evidence-outcomes.test.ts b/backend/src/shared/services/__tests__/transaction-evidence-outcomes.test.ts
new file mode 100644
index 0000000..f25e223
--- /dev/null
+++ b/backend/src/shared/services/__tests__/transaction-evidence-outcomes.test.ts
@@ -0,0 +1,281 @@
+import {
+  afterEach,
+  beforeEach,
+  describe,
+  expect,
+  it,
+  vi,
+} from "vitest";
+
+import {
+  recordEvidenceExecutionOutcome,
+} from "../transaction-evidence.service";
+
+const CORRELATION_ID = "corr-e8";
+
+const HASH_0 =
+  "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+const HASH_1 =
+  "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+
+const response = (data: unknown) =>
+  Promise.resolve({
+    ok: true,
+    status: 200,
+    text: async () =>
+      JSON.stringify(data),
+  } as Response);
+
+function parent() {
+  return {
+    correlationId: CORRELATION_ID,
+    evidenceVersion: "1.0",
+    action: "test",
+    chainId: 43114,
+    network: "avalanche",
+    walletAddress:
+      "0x1111111111111111111111111111111111111111",
+    intent: {},
+    preparedPayloadHash: "0xprepared",
+    preparedMetadata: {
+      totalSteps: 2,
+    },
+    status: "prepared",
+    verificationStatus: null,
+  };
+}
+
+function step(
+  stepIndex: number,
+  txHash: string | null
+) {
+  return {
+    id: `${CORRELATION_ID}:${stepIndex}`,
+    correlationId: CORRELATION_ID,
+    stepIndex,
+    action: `step-${stepIndex}`,
+    chainId: 43114,
+    toAddress:
+      "0x2222222222222222222222222222222222222222",
+    value: "0",
+    dataHash:
+      "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+    preparedStepHash:
+      "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+    txHash,
+  };
+}
+
+function installGateway(
+  step0Hash: string | null,
+  step1Hash: string | null
+) {
+  const patches:
+    Array> = [];
+
+  vi.stubGlobal(
+    "fetch",
+    vi.fn(async (
+      input: string | URL | Request,
+      init?: RequestInit
+    ) => {
+      const url = String(input);
+      const method =
+        init?.method || "GET";
+
+      if (
+        method === "GET" &&
+        url.endsWith(
+          `/v1/transaction-evidence/${CORRELATION_ID}`
+        )
+      ) {
+        return response({
+          data: parent(),
+        });
+      }
+
+      if (
+        method === "GET" &&
+        url.endsWith(
+          `/v1/transaction-evidence-steps/${CORRELATION_ID}%3A0`
+        )
+      ) {
+        return response({
+          data: step(0, step0Hash),
+        });
+      }
+
+      if (
+        method === "GET" &&
+        url.endsWith(
+          `/v1/transaction-evidence-steps/${CORRELATION_ID}%3A1`
+        )
+      ) {
+        return response({
+          data: step(1, step1Hash),
+        });
+      }
+
+      if (method === "PATCH") {
+        patches.push(
+          JSON.parse(
+            String(
+              init?.body || "{}"
+            )
+          )
+        );
+
+        return response({
+          data: {},
+        });
+      }
+
+      throw new Error(
+        `Unexpected request: ${method} ${url}`
+      );
+    })
+  );
+
+  return patches;
+}
+
+describe(
+  "transaction evidence execution outcomes",
+  () => {
+    beforeEach(() => {
+      process.env.PHASE2_EVIDENCE_ENABLED =
+        "true";
+      process.env.DB_GATEWAY_URL =
+        "http://gateway.test";
+      process.env.DB_GATEWAY_SERVICE_TOKEN =
+        "test-token";
+      process.env.DB_GATEWAY_TENANT_ID =
+        "panorama-test";
+    });
+
+    afterEach(() => {
+      vi.unstubAllGlobals();
+      vi.restoreAllMocks();
+    });
+
+    it(
+      "records cancellation before any durable submission exists",
+      async () => {
+        const patches =
+          installGateway(null, null);
+
+        await expect(
+          recordEvidenceExecutionOutcome({
+            correlationId:
+              CORRELATION_ID,
+            outcome:
+              "cancelled-before-submission",
+            reason: "wallet rejected",
+          })
+        ).resolves.toMatchObject({
+          status:
+            "cancelled-before-submission",
+          verified: false,
+        });
+
+        expect(patches).toHaveLength(1);
+
+        expect(patches[0]).toMatchObject({
+          status:
+            "cancelled-before-submission",
+          errorReason: "wallet rejected",
+        });
+      }
+    );
+
+    it(
+      "refuses cancellation after a durable transaction hash exists",
+      async () => {
+        installGateway(HASH_0, null);
+
+        await expect(
+          recordEvidenceExecutionOutcome({
+            correlationId:
+              CORRELATION_ID,
+            outcome:
+              "cancelled-before-submission",
+          })
+        ).rejects.toThrow(
+          "Cannot record cancelled-before-submission"
+        );
+      }
+    );
+
+    it(
+      "records partially-executed when some but not all steps have durable hashes",
+      async () => {
+        const patches =
+          installGateway(HASH_0, null);
+
+        await expect(
+          recordEvidenceExecutionOutcome({
+            correlationId:
+              CORRELATION_ID,
+            outcome:
+              "partially-executed",
+            reason:
+              "later wallet submission failed",
+          })
+        ).resolves.toMatchObject({
+          status:
+            "partially-executed",
+          verificationStatus: "pending",
+          verified: false,
+        });
+
+        expect(patches).toHaveLength(1);
+
+        expect(patches[0]).toMatchObject({
+          status: "partially-executed",
+          errorReason:
+            "later wallet submission failed",
+        });
+      }
+    );
+
+    it(
+      "refuses partially-executed when no durable hashes exist",
+      async () => {
+        installGateway(null, null);
+
+        await expect(
+          recordEvidenceExecutionOutcome({
+            correlationId:
+              CORRELATION_ID,
+            outcome:
+              "partially-executed",
+          })
+        ).rejects.toThrow(
+          "at least one but not all"
+        );
+      }
+    );
+
+    it(
+      "refuses partially-executed when every prepared step has a durable hash",
+      async () => {
+        installGateway(
+          HASH_0,
+          HASH_1
+        );
+
+        await expect(
+          recordEvidenceExecutionOutcome({
+            correlationId:
+              CORRELATION_ID,
+            outcome:
+              "partially-executed",
+          })
+        ).rejects.toThrow(
+          "at least one but not all"
+        );
+      }
+    );
+  }
+);
diff --git a/backend/src/shared/services/transaction-evidence.service.ts b/backend/src/shared/services/transaction-evidence.service.ts
index 0f10d92..182ca29 100644
--- a/backend/src/shared/services/transaction-evidence.service.ts
+++ b/backend/src/shared/services/transaction-evidence.service.ts
@@ -433,10 +433,13 @@ interface StoredEvidenceStep {
 export type EvidenceLifecycleStatus =
   | "intent-recorded"
   | "prepared"
+  | "cancelled-before-submission"
   | "partially-submitted"
+  | "partially-executed"
   | "submitted"
   | "partially-confirmed"
   | "confirmed"
+  | "reverted"
   | "verified"
   | "verification-failed";
 
@@ -491,6 +494,13 @@ export function deriveEvidenceLifecycleStatus(
   const allConfirmed =
     steps.every(isConfirmed);
 
+  const anyReverted =
+    steps.some(
+      (step) =>
+        isConfirmed(step) &&
+        step.receiptStatus === 0
+    );
+
   const anyVerificationFailed =
     steps.some(
       (step) =>
@@ -503,6 +513,16 @@ export function deriveEvidenceLifecycleStatus(
       (step) => step.verified === true
     );
 
+  // A reverted receipt is independently retrieved chain evidence.
+  // It therefore outranks generic verification failure semantics.
+  if (anyReverted) {
+    return {
+      status: "reverted",
+      verificationStatus: "failed",
+      verified: false,
+    };
+  }
+
   if (allVerified) {
     return {
       status: "verified",
@@ -519,6 +539,22 @@ export function deriveEvidenceLifecycleStatus(
     };
   }
 
+  // An explicit partial-execution outcome means execution stopped
+  // before all committed steps obtained durable submission hashes.
+  // Preserve that outcome even if earlier submitted steps later confirm.
+  if (
+    currentStatus === "partially-executed" &&
+    anySubmitted &&
+    !allSubmitted
+  ) {
+    return {
+      status: "partially-executed",
+      verificationStatus:
+        currentVerificationStatus || "pending",
+      verified: false,
+    };
+  }
+
   if (allConfirmed) {
     return {
       status: "confirmed",
@@ -551,6 +587,20 @@ export function deriveEvidenceLifecycleStatus(
     };
   }
 
+  // Cancellation is valid only while no durable submission exists.
+  // If a hash appears later, the submission branches above take over.
+  if (
+    currentStatus === "cancelled-before-submission" &&
+    !anySubmitted
+  ) {
+    return {
+      status: "cancelled-before-submission",
+      verificationStatus:
+        currentVerificationStatus,
+      verified: false,
+    };
+  }
+
   return {
     status: currentStatus,
     verificationStatus:
@@ -559,6 +609,16 @@ export function deriveEvidenceLifecycleStatus(
   };
 }
 
+export type EvidenceExecutionOutcome =
+  | "cancelled-before-submission"
+  | "partially-executed";
+
+export interface RecordEvidenceExecutionOutcomeInput {
+  correlationId: string;
+  outcome: EvidenceExecutionOutcome;
+  reason?: string;
+}
+
 export interface SubmitEvidenceInput {
   correlationId: string;
   stepIndex: number;
@@ -803,6 +863,77 @@ function canonicalLogsHash(
   return hashJson(canonical);
 }
 
+export async function recordEvidenceExecutionOutcome(
+  input: RecordEvidenceExecutionOutcomeInput
+): Promise {
+  if (!evidenceEnabled()) {
+    throw new Error(
+      "Transaction evidence outcome reporting requires PHASE2_EVIDENCE_ENABLED=true"
+    );
+  }
+
+  if (
+    input.outcome !== "cancelled-before-submission" &&
+    input.outcome !== "partially-executed"
+  ) {
+    throw new Error(
+      `Unsupported evidence execution outcome: ${input.outcome}`
+    );
+  }
+
+  const { evidence, steps } =
+    await getTransactionEvidence(input.correlationId);
+
+  const submittedCount =
+    steps.filter(
+      (step) => Boolean(step.txHash)
+    ).length;
+
+  if (
+    input.outcome === "cancelled-before-submission" &&
+    submittedCount !== 0
+  ) {
+    throw new Error(
+      "Cannot record cancelled-before-submission after a durable transaction submission"
+    );
+  }
+
+  if (
+    input.outcome === "partially-executed" &&
+    (
+      submittedCount === 0 ||
+      submittedCount >= steps.length
+    )
+  ) {
+    throw new Error(
+      "partially-executed requires at least one but not all prepared steps to have durable transaction hashes"
+    );
+  }
+
+  const verificationStatus =
+    input.outcome === "partially-executed"
+      ? evidence.verificationStatus || "pending"
+      : evidence.verificationStatus || null;
+
+  await gatewayPatch(
+    "transaction-evidence",
+    input.correlationId,
+    {
+      status: input.outcome,
+      verificationStatus,
+      errorReason:
+        input.reason?.trim() || null,
+    },
+    `phase2-outcome:${input.correlationId}:${input.outcome}`
+  );
+
+  return {
+    status: input.outcome,
+    verificationStatus,
+    verified: false,
+  };
+}
+
 export async function submitAndVerifyEvidence(
   input: SubmitEvidenceInput
 ): Promise {

From 7f5e93d5f5e05d17189fd2a955437fff5fbb0dd3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= 
Date: Mon, 31 Aug 2026 13:44:08 +0200
Subject: [PATCH 17/18] ci: enforce backend evidence tests

---
 .github/workflows/ci-execution-backend.yml | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/.github/workflows/ci-execution-backend.yml b/.github/workflows/ci-execution-backend.yml
index de1d4be..96fa138 100644
--- a/.github/workflows/ci-execution-backend.yml
+++ b/.github/workflows/ci-execution-backend.yml
@@ -35,5 +35,8 @@ jobs:
       - name: Install dependencies
         run: npm ci
 
+      - name: Test
+        run: npm test
+
       - name: Build
         run: npm run build

From 1e2d009589522658ab1cf77494a0ba1e87f96281 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikael=20Bj=C3=B6rn?= 
Date: Mon, 31 Aug 2026 14:39:35 +0200
Subject: [PATCH 18/18] test: add Phase 2 Avalanche release gate

---
 .../phase2-avalanche-release-gate.test.ts     | 228 ++++++++++++++++++
 1 file changed, 228 insertions(+)
 create mode 100644 backend/src/__tests__/release/phase2-avalanche-release-gate.test.ts

diff --git a/backend/src/__tests__/release/phase2-avalanche-release-gate.test.ts b/backend/src/__tests__/release/phase2-avalanche-release-gate.test.ts
new file mode 100644
index 0000000..44f2482
--- /dev/null
+++ b/backend/src/__tests__/release/phase2-avalanche-release-gate.test.ts
@@ -0,0 +1,228 @@
+import {
+  describe,
+  expect,
+  it,
+} from "vitest";
+
+import {
+  existsSync,
+  readFileSync,
+} from "node:fs";
+
+import {
+  resolve,
+} from "node:path";
+
+const BACKEND_ROOT =
+  resolve(process.cwd());
+
+const E9_PARENT =
+  "7f5e93d5f5e05d17189fd2a955437fff5fbb0dd3";
+
+const requiredProofs = [
+  "src/__tests__/shared/evidence-bound-preparation.compliance.test.ts",
+
+  "src/__tests__/modules/avax-swap/prepare-swap-evidence-bound.test.ts",
+
+  "src/__tests__/modules/avax-lending/prepare-supply-evidence-bound.test.ts",
+  "src/__tests__/modules/avax-lending/prepare-redeem-evidence-bound.test.ts",
+  "src/__tests__/modules/avax-lending/prepare-borrow-evidence-bound.test.ts",
+  "src/__tests__/modules/avax-lending/prepare-repay-evidence-bound.test.ts",
+  "src/__tests__/modules/avax-lending/lending-evidence-submission.test.ts",
+
+  "src/__tests__/modules/avax-liquid-staking/prepare-stake-evidence-bound.test.ts",
+  "src/__tests__/modules/avax-liquid-staking/prepare-request-unlock-evidence-bound.test.ts",
+  "src/__tests__/modules/avax-liquid-staking/prepare-redeem-evidence-bound.test.ts",
+
+  "src/__tests__/modules/avax-swap/bridge-evidence-two-phase.test.ts",
+  "src/__tests__/modules/avax-swap/bridge-destination-evidence.test.ts",
+
+  "src/shared/services/__tests__/transaction-evidence-lifecycle.test.ts",
+  "src/shared/services/__tests__/transaction-evidence-outcomes.test.ts",
+  "src/shared/services/__tests__/transaction-evidence-independent-verification.test.ts",
+  "src/__tests__/shared/services/transaction-evidence-chain-participation.test.ts",
+];
+
+const preparationUseCases = [
+  "src/modules/avax-swap/usecases/prepare-swap.usecase.ts",
+
+  "src/modules/avax-lending/usecases/prepare-supply.usecase.ts",
+  "src/modules/avax-lending/usecases/prepare-redeem.usecase.ts",
+  "src/modules/avax-lending/usecases/prepare-borrow.usecase.ts",
+  "src/modules/avax-lending/usecases/prepare-repay.usecase.ts",
+
+  "src/modules/avax-liquid-staking/usecases/prepare-stake.usecase.ts",
+  "src/modules/avax-liquid-staking/usecases/prepare-request-unlock.usecase.ts",
+  "src/modules/avax-liquid-staking/usecases/prepare-redeem.usecase.ts",
+];
+
+function source(
+  relativePath: string
+): string {
+  return readFileSync(
+    resolve(
+      BACKEND_ROOT,
+      relativePath
+    ),
+    "utf8"
+  );
+}
+
+describe(
+  "Phase 2 Avalanche release gate",
+  () => {
+    it(
+      "is anchored to the validated EVID-E9 execution-layer baseline",
+      () => {
+        expect(E9_PARENT).toBe(
+          "7f5e93d5f5e05d17189fd2a955437fff5fbb0dd3"
+        );
+      }
+    );
+
+    it(
+      "retains every constituent Phase 2 backend proof required for release",
+      () => {
+        const missing =
+          requiredProofs.filter(
+            relativePath =>
+              !existsSync(
+                resolve(
+                  BACKEND_ROOT,
+                  relativePath
+                )
+              )
+          );
+
+        expect(
+          missing,
+          `Missing Phase 2 release proofs:\n${missing.join("\n")}`
+        ).toEqual([]);
+      }
+    );
+
+    it(
+      "keeps every generic Avalanche prepare use case behind prepareEvidenceBoundBundle",
+      () => {
+        const violations =
+          preparationUseCases.filter(
+            relativePath => {
+              const text =
+                source(relativePath);
+
+              return !(
+                text.includes(
+                  "prepareEvidenceBoundBundle"
+                ) &&
+                text.includes(
+                  "return prepareEvidenceBoundBundle("
+                )
+              );
+            }
+          );
+
+        expect(
+          violations,
+          [
+            "Avalanche preparation bypasses the generic evidence boundary:",
+            ...violations,
+          ].join("\n")
+        ).toEqual([]);
+      }
+    );
+
+    it(
+      "keeps bridge source and destination as explicit Avalanche evidence phases",
+      () => {
+        const bridge =
+          source(
+            "src/modules/avax-swap/services/bridge-evidence.service.ts"
+          );
+
+        expect(bridge).toContain(
+          "beginAvaxBridgeEvidence"
+        );
+
+        expect(bridge).toContain(
+          "commitAvaxBridgeEvidence"
+        );
+
+        expect(bridge).toContain(
+          "beginAvaxBridgeDestinationEvidence"
+        );
+
+        expect(bridge).toContain(
+          "commitAvaxBridgeDestinationEvidence"
+        );
+
+        expect(bridge).toContain(
+          "Avalanche bridge source evidence may only commit chain 43114 transactions"
+        );
+
+        expect(bridge).toContain(
+          "Avalanche bridge destination evidence may only commit chain 43114 transactions"
+        );
+      }
+    );
+
+    it(
+      "retains durable submission before independent verification",
+      () => {
+        const evidenceService =
+          source(
+            "src/shared/services/transaction-evidence.service.ts"
+          );
+
+        const submission =
+          evidenceService.indexOf(
+            "phase2-submission:"
+          );
+
+        const receipt =
+          evidenceService.indexOf(
+            "phase2-receipt:"
+          );
+
+        const verification =
+          evidenceService.indexOf(
+            "phase2-verification:"
+          );
+
+        expect(submission)
+          .toBeGreaterThanOrEqual(0);
+
+        expect(receipt)
+          .toBeGreaterThan(submission);
+
+        expect(verification)
+          .toBeGreaterThan(receipt);
+      }
+    );
+
+    it(
+      "retains failure lifecycle outcomes without permitting client-asserted reverted",
+      () => {
+        const evidenceService =
+          source(
+            "src/shared/services/transaction-evidence.service.ts"
+          );
+
+        expect(evidenceService).toContain(
+          '"cancelled-before-submission"'
+        );
+
+        expect(evidenceService).toContain(
+          '"partially-executed"'
+        );
+
+        expect(evidenceService).toContain(
+          'status: "reverted"'
+        );
+
+        expect(evidenceService).not.toContain(
+          'input.outcome === "reverted"'
+        );
+      }
+    );
+  }
+);