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 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..6c0da03 --- /dev/null +++ b/backend/src/__tests__/modules/avax-lending/lending-evidence-submission.test.ts @@ -0,0 +1,155 @@ +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( + 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, + }); + + mockVerifyEvidenceStep.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, + }); + }); + 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/__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/__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/__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/__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/__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/__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/__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/__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/__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/__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__/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"' + ); + } + ); + } +); 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(() => ({ 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, + }); + }, +}); 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/__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/modules/avax-lending/controllers/avax-lending.controller.ts b/backend/src/modules/avax-lending/controllers/avax-lending.controller.ts index 9d6f3f1..4994dc6 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,12 @@ 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, + verifyEvidenceStep, + recordEvidenceExecutionOutcome +} 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 +67,206 @@ 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 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; + 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 65a8daa..dafe652 100644 --- a/backend/src/modules/avax-lending/routes/avax-lending.routes.ts +++ b/backend/src/modules/avax-lending/routes/avax-lending.routes.ts @@ -38,12 +38,48 @@ 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/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/outcomes", + validateRequired("outcome"), + executionTimeout(), + ctrl.recordEvidenceOutcome +); + +avaxLendingRoutes.post( + "/evidence/:correlationId/verifications", + validateRequired("stepIndex"), + executionTimeout(), + ctrl.verifyEvidence +); + /** * POST /avax/lending/prepare-redeem * 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-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, + }, + }; }, - }; + }); } 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, + }; + }, + }); } 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,
+        },
+      };
     },
-  };
+  });
 }
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, + }; }, - }; + }); } 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, + }, + }; + }, + }); } 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, + }, + }; + }, + }); } 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(), + }, + }; + }, + }); } 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..56372ae 100644 --- a/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts +++ b/backend/src/modules/avax-swap/controllers/avax-swap.controller.ts @@ -5,13 +5,21 @@ import { executePrepareAvaxSwap } from "../usecases/prepare-swap.usecase"; import { getEnabledSwapPairs } from "../config/avax-swap-pairs"; import { submitAndVerifyEvidence, + verifyEvidenceStep, getTransactionEvidence, exportTransactionEvidence, exportTransactionEvidenceByWallet, exportTransactionEvidenceAdmin, isPhase2EvidenceAdmin, + recordEvidenceExecutionOutcome } from "../../../shared/services/transaction-evidence.service"; import { AppError } from "../../../shared/errorCodes"; +import { + beginAvaxBridgeEvidence, + beginAvaxBridgeDestinationEvidence, + commitAvaxBridgeEvidence, + commitAvaxBridgeDestinationEvidence, +} from "../services/bridge-evidence.service"; export const getQuote = asyncHandler(async (req: Request, res: Response) => { const result = await executeGetAvaxQuote({ @@ -39,6 +47,87 @@ 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 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; @@ -82,6 +171,141 @@ 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; + 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 b669899..49d2f1f 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,96 @@ 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/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 @@ -67,6 +157,29 @@ 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/outcomes", + validateRequired("outcome"), + executionTimeout(), + ctrl.recordEvidenceOutcome +); + +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/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..6817c87 --- /dev/null +++ b/backend/src/modules/avax-swap/services/bridge-evidence.service.ts @@ -0,0 +1,451 @@ +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, + } + ); +} + +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, + } + ); +} 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/__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..edcdc40 --- /dev/null +++ b/backend/src/shared/services/__tests__/transaction-evidence-independent-verification.test.ts @@ -0,0 +1,785 @@ +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 storedParent = { + ...parent, + }; + + const storedStep = { + ...step, + }; + + 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: storedParent, + }); + } + + if ( + url.endsWith( + `/v1/transaction-evidence-steps/${CORRELATION_ID}%3A${STEP_INDEX}` + ) + ) { + return response({ + data: storedStep, + }); + } + } + + if (method === "PATCH") { + 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( + `Unexpected request: ${method} ${url}` + ); + } + ); + + vi.stubGlobal( + "fetch", + fetchMock + ); + + return { + fetchMock, + storedParent, + storedStep, + }; +} + +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 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 } = + 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/__tests__/transaction-evidence-lifecycle.test.ts b/backend/src/shared/services/__tests__/transaction-evidence-lifecycle.test.ts new file mode 100644 index 0000000..d88e93d --- /dev/null +++ b/backend/src/shared/services/__tests__/transaction-evidence-lifecycle.test.ts @@ -0,0 +1,306 @@ +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, + }); + } + ); + + + 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/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, + }; +} diff --git a/backend/src/shared/services/transaction-evidence.service.ts b/backend/src/shared/services/transaction-evidence.service.ts index 175fea7..182ca29 100644 --- a/backend/src/shared/services/transaction-evidence.service.ts +++ b/backend/src/shared/services/transaction-evidence.service.ts @@ -430,6 +430,195 @@ interface StoredEvidenceStep { updatedAt?: string; } +export type EvidenceLifecycleStatus = + | "intent-recorded" + | "prepared" + | "cancelled-before-submission" + | "partially-submitted" + | "partially-executed" + | "submitted" + | "partially-confirmed" + | "confirmed" + | "reverted" + | "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 anyReverted = + steps.some( + (step) => + isConfirmed(step) && + step.receiptStatus === 0 + ); + + const anyVerificationFailed = + steps.some( + (step) => + isSubmitted(step) && + step.verified === false + ); + + const allVerified = + steps.every( + (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", + verificationStatus: "verified", + verified: true, + }; + } + + if (anyVerificationFailed) { + return { + status: "verification-failed", + verificationStatus: "failed", + verified: false, + }; + } + + // 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", + 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, + }; + } + + // 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: + currentVerificationStatus, + verified: currentStatus === "verified", + }; +} + +export type EvidenceExecutionOutcome = + | "cancelled-before-submission" + | "partially-executed"; + +export interface RecordEvidenceExecutionOutcomeInput { + correlationId: string; + outcome: EvidenceExecutionOutcome; + reason?: string; +} + export interface SubmitEvidenceInput { correlationId: string; stepIndex: number; @@ -454,6 +643,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(); @@ -536,30 +746,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 +779,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, @@ -634,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 { @@ -673,37 +973,120 @@ 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); + + 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); @@ -716,8 +1099,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 = @@ -727,7 +1109,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 && @@ -782,6 +1164,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, @@ -803,19 +1188,30 @@ export async function submitAndVerifyEvidence( logsHash: receiptEvidence.logsHash, receipt: receiptEvidence, receiptRetrievedAt: retrievedAt, + }, + `phase2-receipt:${input.correlationId}:${input.stepIndex}:${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:${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); @@ -824,7 +1220,7 @@ export async function submitAndVerifyEvidence( { correlationId: input.correlationId, stepIndex: input.stepIndex, - txHash: input.txHash, + txHash: txHash, chainId: actualChainId, verified, }, @@ -834,7 +1230,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), @@ -921,48 +1317,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}` ); }