From 6ba362876c227262bfc8ac55c9c3fbcf1ac22415 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Tue, 19 May 2026 09:05:48 +0200 Subject: [PATCH 1/5] feat: enhance DRep certificate handling and testing - Introduced `applyDRepCert` function to streamline DRep certificate operations (register, update, retire) in `buildDRepCertTx.ts`. - Updated `RegisterDRep`, `Retire`, and `UpdateDRep` components to utilize the new `applyDRepCert` function for better code reuse and clarity. - Added Jest tests for DRep certificate functionality in `drepCert.test.ts`, covering various scenarios including registration, update, and retirement. - Created utility functions and fixtures for testing in `cborUtils.ts`, `fixtures.ts`, and `mockProvider.ts`. - Established a GitHub Actions workflow for running unit tests and uploading coverage reports. - Set coverage thresholds for critical files to ensure code quality. --- .github/workflows/unit-tests.yml | 40 ++ jest.config.mjs | 4 + src/__tests__/tx-builders/cborUtils.ts | 33 ++ src/__tests__/tx-builders/drepCert.test.ts | 178 +++++++++ src/__tests__/tx-builders/fixtures.ts | 47 +++ .../tx-builders/infrastructure.test.ts | 17 + src/__tests__/tx-builders/mockProvider.ts | 47 +++ src/__tests__/tx-builders/proxy.test.ts | 348 ++++++++++++++++++ src/__tests__/tx-builders/stakingCert.test.ts | 186 ++++++++++ src/__tests__/tx-builders/testTxBuilder.ts | 10 + .../wallet/governance/drep/registerDrep.tsx | 28 +- .../pages/wallet/governance/drep/retire.tsx | 28 +- .../wallet/governance/drep/updateDrep.tsx | 34 +- src/lib/tx-builders/buildDRepCertTx.ts | 48 +++ 14 files changed, 987 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/unit-tests.yml create mode 100644 src/__tests__/tx-builders/cborUtils.ts create mode 100644 src/__tests__/tx-builders/drepCert.test.ts create mode 100644 src/__tests__/tx-builders/fixtures.ts create mode 100644 src/__tests__/tx-builders/infrastructure.test.ts create mode 100644 src/__tests__/tx-builders/mockProvider.ts create mode 100644 src/__tests__/tx-builders/proxy.test.ts create mode 100644 src/__tests__/tx-builders/stakingCert.test.ts create mode 100644 src/__tests__/tx-builders/testTxBuilder.ts create mode 100644 src/lib/tx-builders/buildDRepCertTx.ts diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000..dbda52e9 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,40 @@ +name: Unit Tests + +on: + pull_request: + branches: + - main + - preprod + push: + branches: + - main + workflow_dispatch: + +jobs: + unit-tests: + name: Transaction builder unit tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Run transaction builder tests + run: npm run test:ci -- --testPathPattern="src/__tests__/tx-builders" + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: tx-builder-coverage + path: coverage/ + retention-days: 7 diff --git a/jest.config.mjs b/jest.config.mjs index 3cb3583f..b991f29d 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -28,6 +28,10 @@ export default { coverageProvider: 'v8', coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov', 'html'], + coverageThreshold: { + 'src/utils/stakingCertificates.ts': { lines: 90 }, + 'src/lib/tx-builders/buildDRepCertTx.ts': { lines: 90 }, + }, setupFilesAfterEnv: ['/src/__tests__/setup.ts'], testTimeout: 10000, verbose: true, diff --git a/src/__tests__/tx-builders/cborUtils.ts b/src/__tests__/tx-builders/cborUtils.ts new file mode 100644 index 00000000..a045539f --- /dev/null +++ b/src/__tests__/tx-builders/cborUtils.ts @@ -0,0 +1,33 @@ +import { decode } from "cbor-x"; + +export const TX_BODY_KEYS = { + INPUTS: 0, + OUTPUTS: 1, + FEE: 2, + CERTS: 4, + WITHDRAWALS: 5, + MINT: 9, + VOTES: 19, +} as const; + +export const CERT_KIND = { + STAKE_REGISTRATION: 0, + STAKE_DEREGISTRATION: 1, + STAKE_DELEGATION: 2, + DREP_REGISTRATION: 16, + DREP_DEREGISTRATION: 17, + DREP_UPDATE: 18, +} as const; + +export function decodeTxBody(cbor: string): Map { + const [body] = decode(Buffer.from(cbor, "hex")) as [Map]; + return body; +} + +export function getCerts(body: Map): unknown[][] { + return (body.get(TX_BODY_KEYS.CERTS) as unknown[][] | undefined) ?? []; +} + +export function getWithdrawals(body: Map): Map { + return (body.get(TX_BODY_KEYS.WITHDRAWALS) as Map | undefined) ?? new Map(); +} diff --git a/src/__tests__/tx-builders/drepCert.test.ts b/src/__tests__/tx-builders/drepCert.test.ts new file mode 100644 index 00000000..1791a470 --- /dev/null +++ b/src/__tests__/tx-builders/drepCert.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from "@jest/globals"; +import { applyDRepCert } from "@/lib/tx-builders/buildDRepCertTx"; +import { + FIXTURE_UTXOS, + CHANGE_ADDRESS, + DREP_SCRIPT_CBOR, + STAKING_SCRIPT_CBOR, +} from "./fixtures"; + +const DREP_ID = "drep1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqe7g7h6"; +const ANCHOR = { + anchorUrl: "https://example.com/drep.json", + anchorDataHash: "0".repeat(64), +}; + +interface BuilderCall { + method: string; + args: unknown[]; +} + +function createDRepBuilderMock() { + const calls: BuilderCall[] = []; + + const builder = { + txIn: (...args: unknown[]) => { calls.push({ method: "txIn", args }); return builder; }, + txInScript: (...args: unknown[]) => { calls.push({ method: "txInScript", args }); return builder; }, + drepRegistrationCertificate: (...args: unknown[]) => { calls.push({ method: "drepRegistrationCertificate", args }); return builder; }, + drepUpdateCertificate: (...args: unknown[]) => { calls.push({ method: "drepUpdateCertificate", args }); return builder; }, + drepDeregistrationCertificate: (...args: unknown[]) => { calls.push({ method: "drepDeregistrationCertificate", args }); return builder; }, + certificateScript: (...args: unknown[]) => { calls.push({ method: "certificateScript", args }); return builder; }, + changeAddress: (...args: unknown[]) => { calls.push({ method: "changeAddress", args }); return builder; }, + }; + + return { builder, calls }; +} + +describe("applyDRepCert", () => { + it("register calls drepRegistrationCertificate with anchor", () => { + const { builder, calls } = createDRepBuilderMock(); + applyDRepCert(builder as never, { + action: "register", + dRepId: DREP_ID, + drepCbor: DREP_SCRIPT_CBOR, + scriptCbor: DREP_SCRIPT_CBOR, + changeAddress: CHANGE_ADDRESS, + utxos: FIXTURE_UTXOS, + anchor: ANCHOR, + }); + + const certCall = calls.find(c => c.method === "drepRegistrationCertificate"); + expect(certCall).toBeDefined(); + expect(certCall!.args[0]).toBe(DREP_ID); + expect(certCall!.args[1]).toEqual(ANCHOR); + }); + + it("update calls drepUpdateCertificate with anchor", () => { + const { builder, calls } = createDRepBuilderMock(); + applyDRepCert(builder as never, { + action: "update", + dRepId: DREP_ID, + drepCbor: DREP_SCRIPT_CBOR, + scriptCbor: DREP_SCRIPT_CBOR, + changeAddress: CHANGE_ADDRESS, + utxos: FIXTURE_UTXOS, + anchor: ANCHOR, + }); + + const certCall = calls.find(c => c.method === "drepUpdateCertificate"); + expect(certCall).toBeDefined(); + expect(certCall!.args[0]).toBe(DREP_ID); + expect(certCall!.args[1]).toEqual(ANCHOR); + }); + + it("retire calls drepDeregistrationCertificate without anchor", () => { + const { builder, calls } = createDRepBuilderMock(); + applyDRepCert(builder as never, { + action: "retire", + dRepId: DREP_ID, + drepCbor: DREP_SCRIPT_CBOR, + scriptCbor: DREP_SCRIPT_CBOR, + changeAddress: CHANGE_ADDRESS, + utxos: FIXTURE_UTXOS, + }); + + const certCall = calls.find(c => c.method === "drepDeregistrationCertificate"); + expect(certCall).toBeDefined(); + expect(certCall!.args[0]).toBe(DREP_ID); + }); + + it("register without anchor throws", () => { + const { builder } = createDRepBuilderMock(); + expect(() => { + applyDRepCert(builder as never, { + action: "register", + dRepId: DREP_ID, + drepCbor: DREP_SCRIPT_CBOR, + scriptCbor: DREP_SCRIPT_CBOR, + changeAddress: CHANGE_ADDRESS, + utxos: FIXTURE_UTXOS, + }); + }).toThrow("anchor is required for DRep register"); + }); + + it("update without anchor throws", () => { + const { builder } = createDRepBuilderMock(); + expect(() => { + applyDRepCert(builder as never, { + action: "update", + dRepId: DREP_ID, + drepCbor: DREP_SCRIPT_CBOR, + scriptCbor: DREP_SCRIPT_CBOR, + changeAddress: CHANGE_ADDRESS, + utxos: FIXTURE_UTXOS, + }); + }).toThrow("anchor is required for DRep update"); + }); + + it("legacy wallet: skips certificateScript when drepCbor === scriptCbor", () => { + const { builder, calls } = createDRepBuilderMock(); + applyDRepCert(builder as never, { + action: "retire", + dRepId: DREP_ID, + drepCbor: DREP_SCRIPT_CBOR, + scriptCbor: DREP_SCRIPT_CBOR, + changeAddress: CHANGE_ADDRESS, + utxos: FIXTURE_UTXOS, + }); + + expect(calls.find(c => c.method === "certificateScript")).toBeUndefined(); + }); + + it("SDK wallet: adds certificateScript when drepCbor !== scriptCbor", () => { + const { builder, calls } = createDRepBuilderMock(); + applyDRepCert(builder as never, { + action: "retire", + dRepId: DREP_ID, + drepCbor: DREP_SCRIPT_CBOR, + scriptCbor: STAKING_SCRIPT_CBOR, + changeAddress: CHANGE_ADDRESS, + utxos: FIXTURE_UTXOS, + }); + + const certScriptCall = calls.find(c => c.method === "certificateScript"); + expect(certScriptCall).toBeDefined(); + expect(certScriptCall!.args[0]).toBe(DREP_SCRIPT_CBOR); + }); + + it("calls txIn + txInScript for each UTxO", () => { + const { builder, calls } = createDRepBuilderMock(); + applyDRepCert(builder as never, { + action: "retire", + dRepId: DREP_ID, + drepCbor: DREP_SCRIPT_CBOR, + scriptCbor: DREP_SCRIPT_CBOR, + changeAddress: CHANGE_ADDRESS, + utxos: FIXTURE_UTXOS, + }); + + expect(calls.filter(c => c.method === "txIn")).toHaveLength(FIXTURE_UTXOS.length); + expect(calls.filter(c => c.method === "txInScript")).toHaveLength(FIXTURE_UTXOS.length); + }); + + it("sets changeAddress", () => { + const { builder, calls } = createDRepBuilderMock(); + applyDRepCert(builder as never, { + action: "retire", + dRepId: DREP_ID, + drepCbor: DREP_SCRIPT_CBOR, + scriptCbor: DREP_SCRIPT_CBOR, + changeAddress: CHANGE_ADDRESS, + utxos: FIXTURE_UTXOS, + }); + + const changeCall = calls.find(c => c.method === "changeAddress"); + expect(changeCall).toBeDefined(); + expect(changeCall!.args[0]).toBe(CHANGE_ADDRESS); + }); +}); diff --git a/src/__tests__/tx-builders/fixtures.ts b/src/__tests__/tx-builders/fixtures.ts new file mode 100644 index 00000000..6cc85848 --- /dev/null +++ b/src/__tests__/tx-builders/fixtures.ts @@ -0,0 +1,47 @@ +import type { UTxO } from "@meshsdk/core"; + +export const SCRIPT_ADDRESS = "addr_test1wqag3rt979nep9g065n4qdfn8475ztnsarek70g8cxu4wlgj6veh3"; +export const CHANGE_ADDRESS = "addr_test1qpbotintegrationfixture000000000000000000000000"; +export const REWARD_ADDRESS = "stake_test1uzqrj44szyh7hg9e7xvxcd58f3vl9kekk6cxn9l5jt0x00cxm2dq4"; +export const POOL_HEX = "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy"; + +export const PARAM_UTXO = { + txHash: "a".repeat(64), + outputIndex: 0, +} as const; + +export const STAKING_SCRIPT_CBOR = "5901" + "00".repeat(200); +export const DREP_SCRIPT_CBOR = "5901" + "01".repeat(200); + +function mkUtxo( + txHash: string, + outputIndex: number, + address: string, + lovelace: string, + token?: { unit: string; quantity: string }, +): UTxO { + return { + input: { txHash, outputIndex }, + output: { + address, + amount: token + ? [{ unit: "lovelace", quantity: lovelace }, token] + : [{ unit: "lovelace", quantity: lovelace }], + }, + }; +} + +export const FIXTURE_UTXO_LOVELACE: UTxO = mkUtxo( + "b".repeat(64), 0, SCRIPT_ADDRESS, "10000000", +); + +export const FIXTURE_UTXO_TOKEN: UTxO = mkUtxo( + "c".repeat(64), 0, SCRIPT_ADDRESS, "2000000", + { unit: "d".repeat(56) + "6d79546f6b656e", quantity: "1" }, +); + +export const FIXTURE_COLLATERAL: UTxO = mkUtxo( + "e".repeat(64), 0, CHANGE_ADDRESS, "5000000", +); + +export const FIXTURE_UTXOS: UTxO[] = [FIXTURE_UTXO_LOVELACE, FIXTURE_UTXO_TOKEN]; diff --git a/src/__tests__/tx-builders/infrastructure.test.ts b/src/__tests__/tx-builders/infrastructure.test.ts new file mode 100644 index 00000000..2d97d55e --- /dev/null +++ b/src/__tests__/tx-builders/infrastructure.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "@jest/globals"; +import { MeshTxBuilder } from "@meshsdk/core"; +import { getTestTxBuilder } from "./testTxBuilder"; +import { decode } from "cbor-x"; + +describe("tx-builder test infrastructure", () => { + it("constructs MeshTxBuilder with mock provider", () => { + const txBuilder = getTestTxBuilder(); + expect(txBuilder).toBeInstanceOf(MeshTxBuilder); + }); + + it("cbor-x decodes a round-trip Buffer", () => { + const encoded = Buffer.from("82 01 02".replace(/ /g, ""), "hex"); // [1, 2] + const decoded = decode(encoded); + expect(decoded).toEqual([1, 2]); + }); +}); diff --git a/src/__tests__/tx-builders/mockProvider.ts b/src/__tests__/tx-builders/mockProvider.ts new file mode 100644 index 00000000..7cfaefee --- /dev/null +++ b/src/__tests__/tx-builders/mockProvider.ts @@ -0,0 +1,47 @@ +import type { UTxO } from "@meshsdk/core"; +import { FIXTURE_UTXOS, FIXTURE_COLLATERAL } from "./fixtures"; + +const MOCK_PROTOCOL_PARAMS = { + coinsPerUtxoSize: "4310", + feePerByte: 44, + feeFixed: 155381, + minFeeRefScriptCostPerByte: 15, + collateralPercent: 150, + maxCollateralInputs: 3, + priceMem: 0.0577, + priceStep: 0.0000721, + maxTxSize: 16384, + maxValSize: "5000", + maxMemoSize: 64, + keyDeposit: "2000000", + poolDeposit: "500000000", + drepDeposit: "500000000", + govActionDeposit: "100000000000", +}; + +export function createMockProvider(overrides?: { + utxos?: UTxO[]; + collateral?: UTxO; +}) { + const utxos = overrides?.utxos ?? FIXTURE_UTXOS; + const collateral = overrides?.collateral ?? FIXTURE_COLLATERAL; + + return { + fetchAddressUTxOs: jest.fn().mockResolvedValue([...utxos, collateral]), + fetchProtocolParameters: jest.fn().mockResolvedValue(MOCK_PROTOCOL_PARAMS), + fetchAccountInfo: jest.fn().mockResolvedValue({ balance: "0", rewards: "0" }), + fetchAssetAddresses: jest.fn().mockResolvedValue([]), + fetchBlockInfo: jest.fn().mockResolvedValue({}), + fetchCollectionAssets: jest.fn().mockResolvedValue({ assets: [] }), + fetchHandle: jest.fn().mockResolvedValue({}), + fetchHandleAddress: jest.fn().mockResolvedValue(""), + fetchTxInfo: jest.fn().mockResolvedValue({}), + fetchUTxOs: jest.fn().mockResolvedValue([...utxos, collateral]), + + // IEvaluator — returns empty ExUnits; complete() uses zero execution budget + // Intentional: we test structural CBOR correctness, not fee accuracy + evaluateTx: jest.fn().mockResolvedValue([]), + + submitTx: jest.fn().mockResolvedValue("mock-tx-hash"), + }; +} diff --git a/src/__tests__/tx-builders/proxy.test.ts b/src/__tests__/tx-builders/proxy.test.ts new file mode 100644 index 00000000..0372afa2 --- /dev/null +++ b/src/__tests__/tx-builders/proxy.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, jest } from "@jest/globals"; +import type { UTxO } from "@meshsdk/core"; +import { MeshProxyContract } from "@/components/multisig/proxy/offchain"; +import { createMockProvider } from "./mockProvider"; +import { + FIXTURE_UTXOS, + FIXTURE_COLLATERAL, + CHANGE_ADDRESS, + PARAM_UTXO, +} from "./fixtures"; + +// ─── Proxy Mesh Builder Mock ────────────────────────────────────────────────── + +interface BuilderCall { method: string; args: unknown[] } + +/** + * Creates a Proxy that intercepts every method call on the mesh builder, + * records it in `calls`, and returns itself for chaining. + * Also exposes `fetcher`/`evaluator` so contract methods that check + * `this.mesh.fetcher` don't throw "Blockchain provider not found". + */ +function createMeshMock(provider: ReturnType) { + const calls: BuilderCall[] = []; + const mesh: any = new Proxy({}, { + get(_t, method: string) { + if (method === "fetcher") return provider; + if (method === "evaluator") return provider; + // Returning a function for `then` would make the Proxy look like a + // thenable, causing Promise.resolve(mesh) inside async contract methods + // to hang indefinitely. Return undefined to mark it as a plain value. + if (method === "then") return undefined; + return (...args: unknown[]) => { + calls.push({ method, args }); + return mesh; + }; + }, + set() { return true; }, + }); + return { mesh, calls }; +} + +// ─── Contract Factories ─────────────────────────────────────────────────────── + +function makeSetupContract() { + const provider = createMockProvider(); + const { mesh, calls } = createMeshMock(provider); + const contract = new MeshProxyContract( + { mesh, networkId: 0, wallet: {} as any }, + {}, + ); + return { contract, calls }; +} + +/** + * Contract pre-configured with PARAM_UTXO so proxyAddress is set in constructor. + * getWalletInfoForTx is mocked to return an auth token UTxO plus fixture UTxOs. + */ +function makeManageContract(extraWalletUtxos: UTxO[] = []) { + const provider = createMockProvider(); + const { mesh, calls } = createMeshMock(provider); + const contract = new MeshProxyContract( + { mesh, networkId: 0, wallet: {} as any }, + { paramUtxo: PARAM_UTXO }, + ); + + const authPolicyId = contract.getAuthTokenPolicyId(); + + // Auth token unit = policyId with empty name (matches how setupProxy mints) + const authTokenUtxo: UTxO = { + input: { txHash: "f".repeat(64), outputIndex: 0 }, + output: { + address: CHANGE_ADDRESS, + amount: [ + { unit: "lovelace", quantity: "600000000" }, // 600 ADA covers register (505 ADA) + { unit: authPolicyId, quantity: "1" }, + ], + }, + }; + + // FIXTURE_COLLATERAL (5 ADA) satisfies voteProxyDrep's ≥5 ADA collateral search + const walletUtxos = [authTokenUtxo, FIXTURE_COLLATERAL, ...FIXTURE_UTXOS, ...extraWalletUtxos]; + + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: walletUtxos, + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + + return { contract, calls, authPolicyId }; +} + +// ─── Shared Fixtures ────────────────────────────────────────────────────────── + +// txHash matches PARAM_UTXO so auth token policyId is stable after setupProxy +const LARGE_UTXO: UTxO = { + input: { txHash: PARAM_UTXO.txHash, outputIndex: PARAM_UTXO.outputIndex }, + output: { + address: CHANGE_ADDRESS, + amount: [{ unit: "lovelace", quantity: "25000000" }], + }, +}; + +const ANCHOR = { + anchorUrl: "https://example.com/drep.json", + anchorDataHash: "0".repeat(64), +}; + +const VALID_PROPOSAL_ID = "b".repeat(64) + "#0"; +const VALID_PROPOSAL_ID_2 = "c".repeat(64) + "#1"; + +// ─── Pure Computation Tests ─────────────────────────────────────────────────── + +describe("MeshProxyContract — pure computations", () => { + it("getAuthTokenPolicyId returns a 56-char lowercase hex string", () => { + const { contract } = makeManageContract(); + const policyId = contract.getAuthTokenPolicyId(); + expect(typeof policyId).toBe("string"); + expect(policyId).toHaveLength(56); + expect(policyId).toMatch(/^[0-9a-f]+$/); + }); + + it("getAuthTokenPolicyId is deterministic for the same paramUtxo", () => { + const { contract: a } = makeManageContract(); + const { contract: b } = makeManageContract(); + expect(a.getAuthTokenPolicyId()).toBe(b.getAuthTokenPolicyId()); + }); + + it("getDrepId returns a string starting with drep1", () => { + const { contract } = makeManageContract(); + const drepId = contract.getDrepId(); + expect(typeof drepId).toBe("string"); + expect(drepId.startsWith("drep1")).toBe(true); + }); + + it("setProxyAddress returns addr_test1... for networkId 0 and stores proxyAddress", () => { + const { contract } = makeManageContract(); + const addr = contract.setProxyAddress(); + expect(addr.startsWith("addr_test1")).toBe(true); + expect(contract.proxyAddress).toBe(addr); + }); +}); + +// ─── setupProxy Tests ───────────────────────────────────────────────────────── + +describe("MeshProxyContract.setupProxy", () => { + it("mints exactly 10 auth tokens using the correct policyId", async () => { + const { contract, calls } = makeSetupContract(); + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: [LARGE_UTXO], + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + + const result = await contract.setupProxy(); + + const mintCall = calls.find(c => c.method === "mint"); + expect(mintCall).toBeDefined(); + expect(mintCall!.args[0]).toBe("10"); + expect(mintCall!.args[1]).toBe(result.authTokenId); + }); + + it("sends an output to the proxy address", async () => { + const { contract, calls } = makeSetupContract(); + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: [LARGE_UTXO], + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + + const result = await contract.setupProxy(); + + const proxyOut = calls.filter(c => c.method === "txOut").find(c => c.args[0] === result.proxyAddress); + expect(proxyOut).toBeDefined(); + }); + + it("returns paramUtxo matching the selected input", async () => { + const { contract } = makeSetupContract(); + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: [LARGE_UTXO], + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + + const result = await contract.setupProxy(); + + expect(result.paramUtxo).toEqual(LARGE_UTXO.input); + }); + + it("throws when no UTxO holds at least 20 ADA", async () => { + const { contract } = makeSetupContract(); + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: FIXTURE_UTXOS, // max 10 ADA + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + + await expect(contract.setupProxy()).rejects.toThrow("Insufficicient balance"); + }); +}); + +// ─── manageProxyDrep Tests ──────────────────────────────────────────────────── + +describe("MeshProxyContract.manageProxyDrep", () => { + it("register calls drepRegistrationCertificate with drepId and anchor", async () => { + const { contract, calls } = makeManageContract(); + await contract.manageProxyDrep("register", ANCHOR.anchorUrl, ANCHOR.anchorDataHash); + + const certCall = calls.find(c => c.method === "drepRegistrationCertificate"); + expect(certCall).toBeDefined(); + expect(certCall!.args[0]).toBe(contract.getDrepId()); + expect(certCall!.args[1]).toEqual({ anchorUrl: ANCHOR.anchorUrl, anchorDataHash: ANCHOR.anchorDataHash }); + }); + + it("deregister calls drepDeregistrationCertificate with drepId", async () => { + const { contract, calls } = makeManageContract(); + await contract.manageProxyDrep("deregister"); + + const certCall = calls.find(c => c.method === "drepDeregistrationCertificate"); + expect(certCall).toBeDefined(); + expect(certCall!.args[0]).toBe(contract.getDrepId()); + }); + + it("update calls drepUpdateCertificate with drepId and anchor", async () => { + const { contract, calls } = makeManageContract(); + await contract.manageProxyDrep("update", ANCHOR.anchorUrl, ANCHOR.anchorDataHash); + + const certCall = calls.find(c => c.method === "drepUpdateCertificate"); + expect(certCall).toBeDefined(); + expect(certCall!.args[0]).toBe(contract.getDrepId()); + expect(certCall!.args[1]).toEqual({ anchorUrl: ANCHOR.anchorUrl, anchorDataHash: ANCHOR.anchorDataHash }); + }); + + it("register without anchor throws", async () => { + const { contract } = makeManageContract(); + await expect( + contract.manageProxyDrep("register"), + ).rejects.toThrow("Anchor URL and hash are required"); + }); + + it("update without anchor throws", async () => { + const { contract } = makeManageContract(); + await expect( + contract.manageProxyDrep("update"), + ).rejects.toThrow("Anchor URL and hash are required"); + }); + + it("throws when auth token is absent from wallet UTxOs", async () => { + const provider = createMockProvider(); + const { mesh } = createMeshMock(provider); + const contract = new MeshProxyContract( + { mesh, networkId: 0, wallet: {} as any }, + { paramUtxo: PARAM_UTXO }, + ); + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: FIXTURE_UTXOS, // no auth token + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + + await expect( + contract.manageProxyDrep("deregister"), + ).rejects.toThrow("No AuthToken found"); + }); + + it("adds certificateScript with the proxy CBOR", async () => { + const { contract, calls } = makeManageContract(); + await contract.manageProxyDrep("deregister"); + + expect(calls.find(c => c.method === "certificateScript")).toBeDefined(); + }); + + it("sets changeAddress to the wallet address", async () => { + const { contract, calls } = makeManageContract(); + await contract.manageProxyDrep("deregister"); + + const changeCall = calls.find(c => c.method === "changeAddress"); + expect(changeCall).toBeDefined(); + expect(changeCall!.args[0]).toBe(CHANGE_ADDRESS); + }); +}); + +// ─── voteProxyDrep Tests ────────────────────────────────────────────────────── + +describe("MeshProxyContract.voteProxyDrep", () => { + it("throws when votes array is empty", async () => { + const { contract } = makeManageContract(); + await expect(contract.voteProxyDrep([])).rejects.toThrow("No votes provided"); + }); + + it("calls vote once for a single Yes vote", async () => { + const { contract, calls } = makeManageContract(); + await contract.voteProxyDrep([ + { proposalId: VALID_PROPOSAL_ID, voteKind: "Yes" }, + ]); + + const voteCalls = calls.filter(c => c.method === "vote"); + expect(voteCalls).toHaveLength(1); + expect(voteCalls[0]!.args[2]).toEqual({ voteKind: "Yes" }); + }); + + it("calls vote for each proposal in a multi-vote array", async () => { + const { contract, calls } = makeManageContract(); + await contract.voteProxyDrep([ + { proposalId: VALID_PROPOSAL_ID, voteKind: "Yes" }, + { proposalId: VALID_PROPOSAL_ID_2, voteKind: "No" }, + ]); + + const voteCalls = calls.filter(c => c.method === "vote"); + expect(voteCalls).toHaveLength(2); + }); + + it("passes the contract DRep ID to every vote call", async () => { + const { contract, calls } = makeManageContract(); + const drepId = contract.getDrepId(); + + await contract.voteProxyDrep([ + { proposalId: VALID_PROPOSAL_ID, voteKind: "Abstain" }, + ]); + + const voteCall = calls.find(c => c.method === "vote"); + expect(voteCall!.args[0]).toEqual({ type: "DRep", drepId }); + }); + + it("throws when auth token is absent from wallet UTxOs", async () => { + const provider = createMockProvider(); + const { mesh } = createMeshMock(provider); + const contract = new MeshProxyContract( + { mesh, networkId: 0, wallet: {} as any }, + { paramUtxo: PARAM_UTXO }, + ); + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: FIXTURE_UTXOS, // no auth token; 10 ADA UTxO satisfies ≥5 ADA collateral check + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + + await expect( + contract.voteProxyDrep([{ proposalId: VALID_PROPOSAL_ID, voteKind: "Yes" }]), + ).rejects.toThrow("No AuthToken found"); + }); + + it("throws for a malformed proposal ID", async () => { + const { contract } = makeManageContract(); + await expect( + contract.voteProxyDrep([{ proposalId: "invalid-id", voteKind: "Yes" }]), + ).rejects.toThrow("Invalid proposal ID format"); + }); +}); diff --git a/src/__tests__/tx-builders/stakingCert.test.ts b/src/__tests__/tx-builders/stakingCert.test.ts new file mode 100644 index 00000000..7aa7e4f3 --- /dev/null +++ b/src/__tests__/tx-builders/stakingCert.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from "@jest/globals"; +import { + buildStakingCertificateActions, + buildStakingActionConfigs, +} from "@/utils/stakingCertificates"; +import { + REWARD_ADDRESS, + STAKING_SCRIPT_CBOR, + POOL_HEX, +} from "./fixtures"; + +type CertCall = + | { type: "register"; address: string } + | { type: "deregister"; address: string } + | { type: "delegate"; address: string; poolHex: string } + | { type: "withdrawal"; address: string; amount: string }; + +function createCertBuilderMock() { + const calls: CertCall[] = []; + + const builder = { + registerStakeCertificate: (address: string) => { + calls.push({ type: "register", address }); + return builder; + }, + deregisterStakeCertificate: (address: string) => { + calls.push({ type: "deregister", address }); + return builder; + }, + delegateStakeCertificate: (address: string, poolHex: string) => { + calls.push({ type: "delegate", address, poolHex }); + return builder; + }, + certificateScript: (_scriptCbor: string) => builder, + withdrawal: (address: string, amount: string) => { + calls.push({ type: "withdrawal", address, amount }); + return builder; + }, + }; + + return { builder, calls }; +} + +describe("buildStakingCertificateActions", () => { + it("register action calls registerStakeCertificate with reward address", () => { + const { builder, calls } = createCertBuilderMock(); + const actions = buildStakingCertificateActions({ + txBuilder: builder as never, + rewardAddress: REWARD_ADDRESS, + stakingScript: STAKING_SCRIPT_CBOR, + poolHex: POOL_HEX, + }); + + actions.register.execute(); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ type: "register", address: REWARD_ADDRESS }); + }); + + it("deregister action calls deregisterStakeCertificate with reward address", () => { + const { builder, calls } = createCertBuilderMock(); + const actions = buildStakingCertificateActions({ + txBuilder: builder as never, + rewardAddress: REWARD_ADDRESS, + stakingScript: STAKING_SCRIPT_CBOR, + poolHex: POOL_HEX, + }); + + actions.deregister.execute(); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ type: "deregister", address: REWARD_ADDRESS }); + }); + + it("delegate action calls delegateStakeCertificate with reward address and pool", () => { + const { builder, calls } = createCertBuilderMock(); + const actions = buildStakingCertificateActions({ + txBuilder: builder as never, + rewardAddress: REWARD_ADDRESS, + stakingScript: STAKING_SCRIPT_CBOR, + poolHex: POOL_HEX, + }); + + actions.delegate.execute(); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ type: "delegate", address: REWARD_ADDRESS, poolHex: POOL_HEX }); + }); + + it("register_and_delegate action calls both register and delegate in order", () => { + const { builder, calls } = createCertBuilderMock(); + const actions = buildStakingCertificateActions({ + txBuilder: builder as never, + rewardAddress: REWARD_ADDRESS, + stakingScript: STAKING_SCRIPT_CBOR, + poolHex: POOL_HEX, + }); + + actions.register_and_delegate.execute(); + + expect(calls).toHaveLength(2); + expect(calls[0]).toMatchObject({ type: "register", address: REWARD_ADDRESS }); + expect(calls[1]).toMatchObject({ type: "delegate", address: REWARD_ADDRESS, poolHex: POOL_HEX }); + }); + + it("delegate with empty poolHex does not throw (pool validation is on-chain)", () => { + const { builder } = createCertBuilderMock(); + const actions = buildStakingCertificateActions({ + txBuilder: builder as never, + rewardAddress: REWARD_ADDRESS, + stakingScript: STAKING_SCRIPT_CBOR, + poolHex: "", + }); + + expect(() => actions.delegate.execute()).not.toThrow(); + }); + + it("all actions have a description string", () => { + const { builder } = createCertBuilderMock(); + const actions = buildStakingCertificateActions({ + txBuilder: builder as never, + rewardAddress: REWARD_ADDRESS, + stakingScript: STAKING_SCRIPT_CBOR, + poolHex: POOL_HEX, + }); + + for (const [, config] of Object.entries(actions)) { + expect(typeof config.description).toBe("string"); + expect(config.description.length).toBeGreaterThan(0); + } + }); +}); + +describe("buildStakingActionConfigs", () => { + it("withdrawal action calls withdrawal with reward address and rewards amount", () => { + const { builder, calls } = createCertBuilderMock(); + const rewards = "5000000"; + const configs = buildStakingActionConfigs({ + txBuilder: builder as never, + rewardAddress: REWARD_ADDRESS, + stakingScript: STAKING_SCRIPT_CBOR, + poolHex: POOL_HEX, + rewards, + }); + + configs.withdrawal.execute(); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ type: "withdrawal", address: REWARD_ADDRESS, amount: rewards }); + }); + + it("registerAndDelegate action calls both register and delegate in order", () => { + const { builder, calls } = createCertBuilderMock(); + const configs = buildStakingActionConfigs({ + txBuilder: builder as never, + rewardAddress: REWARD_ADDRESS, + stakingScript: STAKING_SCRIPT_CBOR, + poolHex: POOL_HEX, + rewards: "0", + }); + + configs.registerAndDelegate.execute(); + + expect(calls).toHaveLength(2); + expect(calls[0]).toMatchObject({ type: "register", address: REWARD_ADDRESS }); + expect(calls[1]).toMatchObject({ type: "delegate", address: REWARD_ADDRESS, poolHex: POOL_HEX }); + }); + + it("all configs include successTitle and successMessage", () => { + const { builder } = createCertBuilderMock(); + const configs = buildStakingActionConfigs({ + txBuilder: builder as never, + rewardAddress: REWARD_ADDRESS, + stakingScript: STAKING_SCRIPT_CBOR, + poolHex: POOL_HEX, + rewards: "0", + }); + + for (const [, config] of Object.entries(configs)) { + expect(typeof config.successTitle).toBe("string"); + expect(config.successTitle.length).toBeGreaterThan(0); + expect(typeof config.successMessage).toBe("string"); + expect(config.successMessage.length).toBeGreaterThan(0); + } + }); +}); diff --git a/src/__tests__/tx-builders/testTxBuilder.ts b/src/__tests__/tx-builders/testTxBuilder.ts new file mode 100644 index 00000000..17ce724a --- /dev/null +++ b/src/__tests__/tx-builders/testTxBuilder.ts @@ -0,0 +1,10 @@ +import { MeshTxBuilder } from "@meshsdk/core"; +import { createMockProvider } from "./mockProvider"; + +export function getTestTxBuilder(overrides?: Parameters[0]) { + const provider = createMockProvider(overrides); + return new MeshTxBuilder({ + fetcher: provider as any, + evaluator: provider as any, + }); +} diff --git a/src/components/pages/wallet/governance/drep/registerDrep.tsx b/src/components/pages/wallet/governance/drep/registerDrep.tsx index 0c789ac3..521ff6c2 100644 --- a/src/components/pages/wallet/governance/drep/registerDrep.tsx +++ b/src/components/pages/wallet/governance/drep/registerDrep.tsx @@ -20,6 +20,7 @@ import { useProxy } from "@/hooks/useProxy"; import { useToast } from "@/hooks/use-toast"; import { ToastAction } from "@/components/ui/toast"; import useActiveWallet from "@/hooks/useActiveWallet"; +import { applyDRepCert } from "@/lib/tx-builders/buildDRepCertTx"; interface PutResponse { url: string; @@ -186,24 +187,15 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { return; } - for (const utxo of selectedUtxos) { - txBuilder - .txIn( - utxo.input.txHash, - utxo.input.outputIndex, - utxo.output.amount, - utxo.output.address, - ) - .txInScript(scriptCbor); - } - - txBuilder - .drepRegistrationCertificate(dRepId, { - anchorUrl: anchorUrl, - anchorDataHash: anchorHash, - }) - .certificateScript(drepCbor) - .changeAddress(changeAddress); + applyDRepCert(txBuilder, { + action: "register", + dRepId, + drepCbor, + scriptCbor, + changeAddress, + utxos: selectedUtxos, + anchor: { anchorUrl, anchorDataHash: anchorHash }, + }); await newTransaction({ txBuilder, diff --git a/src/components/pages/wallet/governance/drep/retire.tsx b/src/components/pages/wallet/governance/drep/retire.tsx index d961d147..e39d7f1d 100644 --- a/src/components/pages/wallet/governance/drep/retire.tsx +++ b/src/components/pages/wallet/governance/drep/retire.tsx @@ -14,6 +14,7 @@ import { api } from "@/utils/api"; import { useCallback } from "react"; import { useToast } from "@/hooks/use-toast"; import useActiveWallet from "@/hooks/useActiveWallet"; +import { applyDRepCert } from "@/lib/tx-builders/buildDRepCertTx"; export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; manualUtxos: UTxO[] }) { const network = useSiteStore((state) => state.network); @@ -230,25 +231,14 @@ export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; return; } - for (const utxo of selectedUtxos) { - txBuilder.txIn( - utxo.input.txHash, - utxo.input.outputIndex, - utxo.output.amount, - utxo.output.address, - ); - } - - txBuilder - .txInScript(scriptCbor) - .changeAddress(changeAddress) - .drepDeregistrationCertificate(dRepId); - - // Only add certificateScript if it's different from the spending script - // to avoid "extraneous scripts" error - if (drepCbor !== scriptCbor) { - txBuilder.certificateScript(drepCbor); - } + applyDRepCert(txBuilder, { + action: "retire", + dRepId, + drepCbor, + scriptCbor, + changeAddress, + utxos: selectedUtxos, + }); await newTransaction({ txBuilder, diff --git a/src/components/pages/wallet/governance/drep/updateDrep.tsx b/src/components/pages/wallet/governance/drep/updateDrep.tsx index b9365e8b..0f60a518 100644 --- a/src/components/pages/wallet/governance/drep/updateDrep.tsx +++ b/src/components/pages/wallet/governance/drep/updateDrep.tsx @@ -17,6 +17,7 @@ import { useProxy } from "@/hooks/useProxy"; import { MeshProxyContract } from "@/components/multisig/proxy/offchain"; import { api } from "@/utils/api"; import { getProvider } from "@/utils/get-provider"; +import { applyDRepCert } from "@/lib/tx-builders/buildDRepCertTx"; interface PutResponse { url: string; @@ -230,30 +231,15 @@ export default function UpdateDRep({ onClose }: UpdateDRepProps = {}) { return; } - for (const utxo of selectedUtxos) { - txBuilder - .txIn( - utxo.input.txHash, - utxo.input.outputIndex, - utxo.output.amount, - utxo.output.address, - ) - .txInScript(scriptCbor); - } - - txBuilder - .drepUpdateCertificate(dRepId, { - anchorUrl: anchorUrl, - anchorDataHash: anchorHash, - }); - - // Only add certificateScript if it's different from the spending script - // to avoid "extraneous scripts" error - if (drepCbor !== scriptCbor) { - txBuilder.certificateScript(drepCbor); - } - - txBuilder.changeAddress(changeAddress); + applyDRepCert(txBuilder, { + action: "update", + dRepId, + drepCbor, + scriptCbor, + changeAddress, + utxos: selectedUtxos, + anchor: { anchorUrl, anchorDataHash: anchorHash }, + }); await newTransaction({ txBuilder, diff --git a/src/lib/tx-builders/buildDRepCertTx.ts b/src/lib/tx-builders/buildDRepCertTx.ts new file mode 100644 index 00000000..b4112cbb --- /dev/null +++ b/src/lib/tx-builders/buildDRepCertTx.ts @@ -0,0 +1,48 @@ +import type { MeshTxBuilder, UTxO } from "@meshsdk/core"; + +export type DRepCertAction = "register" | "update" | "retire"; + +export interface DRepCertParams { + action: DRepCertAction; + dRepId: string; + drepCbor: string; + scriptCbor: string; + changeAddress: string; + utxos: UTxO[]; + anchor?: { + anchorUrl: string; + anchorDataHash: string; + }; +} + +export function applyDRepCert( + txBuilder: MeshTxBuilder, + params: DRepCertParams, +): void { + const { action, dRepId, drepCbor, scriptCbor, changeAddress, utxos, anchor } = params; + + if ((action === "register" || action === "update") && !anchor) { + throw new Error(`anchor is required for DRep ${action}`); + } + + for (const utxo of utxos) { + txBuilder + .txIn(utxo.input.txHash, utxo.input.outputIndex, utxo.output.amount, utxo.output.address) + .txInScript(scriptCbor); + } + + if (action === "register") { + txBuilder.drepRegistrationCertificate(dRepId, anchor!); + } else if (action === "update") { + txBuilder.drepUpdateCertificate(dRepId, anchor!); + } else { + txBuilder.drepDeregistrationCertificate(dRepId); + } + + // Only add certificateScript if different from spending script (avoids "extraneous scripts" error) + if (drepCbor !== scriptCbor) { + txBuilder.certificateScript(drepCbor); + } + + txBuilder.changeAddress(changeAddress); +} From 0638acfe190641d5182164d80173d53822969ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 20 May 2026 09:20:20 +0200 Subject: [PATCH 2/5] feat: integrate completeTxWithFreshCostModels into proxy transaction handling - Updated proxy transaction APIs to utilize `completeTxWithFreshCostModels` for transaction completion, enhancing cost model handling. - Adjusted `getTxBuilder` to accept a flag for using the CSL serializer, improving flexibility in transaction building. - Enhanced unit tests for proxy cleanup, setup, spend, vote, and DRep certificate APIs to validate the new transaction completion logic. - Added error handling for PPView hash mismatches during transaction submission, ensuring better feedback on transaction integrity issues. --- .../completeTxWithFreshCostModels.test.ts | 160 ++++++++++++++++ src/__tests__/proxyCleanup.bot.test.ts | 13 ++ src/__tests__/proxySetup.bot.test.ts | 16 +- src/__tests__/signTransaction.test.ts | 104 +++++++++- .../server/completeTxWithFreshCostModels.ts | 180 ++++++++++++++++++ src/pages/api/v1/proxyCleanup.ts | 5 +- src/pages/api/v1/proxyDRepCertificate.ts | 5 +- src/pages/api/v1/proxySetup.ts | 5 +- src/pages/api/v1/proxySpend.ts | 5 +- src/pages/api/v1/proxyVote.ts | 5 +- src/utils/get-tx-builder.ts | 6 +- src/utils/txScriptRecovery.ts | 13 ++ 12 files changed, 502 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/completeTxWithFreshCostModels.test.ts create mode 100644 src/lib/server/completeTxWithFreshCostModels.ts diff --git a/src/__tests__/completeTxWithFreshCostModels.test.ts b/src/__tests__/completeTxWithFreshCostModels.test.ts new file mode 100644 index 00000000..4467dd01 --- /dev/null +++ b/src/__tests__/completeTxWithFreshCostModels.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +const insertedLanguages: string[] = []; +const costModelValues: number[][] = []; +const setScriptDataHashMock = jest.fn(); +const hashScriptDataMock = jest.fn(() => ({ hash: "fresh-script-data-hash" })); + +class MockCostModel { + values: number[] = []; + + static new() { + const model = new MockCostModel(); + costModelValues.push(model.values); + return model; + } + + set(index: number, cost: { value: number }) { + this.values[index] = cost.value; + return cost; + } +} + +class MockCostmdls { + static new() { + return new MockCostmdls(); + } + + insert(language: { label: string }, _costModel: MockCostModel) { + insertedLanguages.push(language.label); + return undefined; + } +} + +class MockLanguage { + static new_plutus_v1() { + return { label: "V1" }; + } + + static new_plutus_v2() { + return { label: "V2" }; + } + + static new_plutus_v3() { + return { label: "V3" }; + } +} + +class MockInt { + static new_i32(value: number) { + return { value }; + } +} + +class MockTransaction { + private static redeemerCount = 0; + private static updatedHex = "updated-tx-hex"; + + static configure(args: { redeemerCount: number; updatedHex?: string }) { + MockTransaction.redeemerCount = args.redeemerCount; + MockTransaction.updatedHex = args.updatedHex ?? "updated-tx-hex"; + } + + static from_hex(hex: string) { + return { + witness_set: () => ({ + redeemers: () => + MockTransaction.redeemerCount > 0 + ? { len: () => MockTransaction.redeemerCount } + : undefined, + plutus_data: () => ({ datum: true }), + }), + body: () => ({ set_script_data_hash: setScriptDataHashMock }), + auxiliary_data: () => ({ metadata: true }), + is_valid: () => true, + to_hex: () => hex, + }; + } + + static new() { + return { + set_is_valid: jest.fn(), + to_hex: () => MockTransaction.updatedHex, + }; + } +} + +jest.mock( + "@meshsdk/core-csl", + () => ({ + __esModule: true, + csl: { + CostModel: MockCostModel, + Costmdls: MockCostmdls, + Int: MockInt, + Language: MockLanguage, + Transaction: MockTransaction, + hash_script_data: hashScriptDataMock, + }, + }), + { virtual: true }, +); + +jest.mock( + "@/env", + () => ({ + __esModule: true, + env: { + NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD: "preprod-key", + NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET: "mainnet-key", + }, + }), + { virtual: true }, +); + +describe("refreshScriptDataHash", () => { + beforeEach(() => { + insertedLanguages.length = 0; + costModelValues.length = 0; + setScriptDataHashMock.mockClear(); + hashScriptDataMock.mockClear(); + MockTransaction.configure({ redeemerCount: 0 }); + }); + + it("leaves transactions without redeemers unchanged", async () => { + const { refreshScriptDataHash } = await import("@/lib/server/completeTxWithFreshCostModels"); + + expect(refreshScriptDataHash("unsigned-tx-hex", {}, {})).toBe("unsigned-tx-hex"); + expect(hashScriptDataMock).not.toHaveBeenCalled(); + expect(setScriptDataHashMock).not.toHaveBeenCalled(); + }); + + it("recomputes script data hash with current Plutus V3 cost model", async () => { + MockTransaction.configure({ redeemerCount: 1, updatedHex: "fresh-tx-hex" }); + const { refreshScriptDataHash } = await import("@/lib/server/completeTxWithFreshCostModels"); + + const refreshed = refreshScriptDataHash( + "unsigned-tx-hex", + { + PlutusV3: { + "builtin-a": 10, + "builtin-b": 20, + }, + }, + { + mints: [ + { + type: "Plutus", + scriptSource: { script: { version: "V3" } }, + }, + ], + }, + ); + + expect(refreshed).toBe("fresh-tx-hex"); + expect(insertedLanguages).toEqual(["V3"]); + expect(costModelValues).toEqual([[10, 20]]); + expect(hashScriptDataMock).toHaveBeenCalled(); + expect(setScriptDataHashMock).toHaveBeenCalledWith({ hash: "fresh-script-data-hash" }); + }); +}); diff --git a/src/__tests__/proxyCleanup.bot.test.ts b/src/__tests__/proxyCleanup.bot.test.ts index 5f3827a4..bdc8bd40 100644 --- a/src/__tests__/proxyCleanup.bot.test.ts +++ b/src/__tests__/proxyCleanup.bot.test.ts @@ -21,6 +21,7 @@ const buildProxyCleanupSweepTxMock: jest.Mock = jest.fn(); const buildProxyCleanupTxMock: jest.Mock = jest.fn(); const deriveProxyScriptsMock: jest.Mock = jest.fn(); const createPendingMultisigTransactionMock: jest.Mock = jest.fn(); +const completeTxWithFreshCostModelsMock: jest.Mock = jest.fn(); const completeMock: jest.Mock = jest.fn(); const getTxBuilderMock: jest.Mock = jest.fn(); const fetchAddressUTxOsMock: jest.Mock = jest.fn(); @@ -93,6 +94,11 @@ jest.mock("@/lib/server/createPendingMultisigTransaction", () => ({ createPendingMultisigTransaction: createPendingMultisigTransactionMock, }), { virtual: true }); +jest.mock("@/lib/server/completeTxWithFreshCostModels", () => ({ + __esModule: true, + completeTxWithFreshCostModels: completeTxWithFreshCostModelsMock, +}), { virtual: true }); + jest.mock("@/utils/get-provider", () => ({ __esModule: true, getProvider: () => ({ fetchAddressUTxOs: fetchAddressUTxOsMock }), @@ -140,6 +146,7 @@ beforeEach(() => { buildProxyCleanupTxMock.mockReturnValue({ burnedAuthTokens: "10" }); (completeMock as any).mockResolvedValue("tx-cbor"); getTxBuilderMock.mockReturnValue({ complete: completeMock, meshTxBuilderBody: {} }); + (completeTxWithFreshCostModelsMock as any).mockResolvedValue("fresh-tx-cbor"); (createPendingMultisigTransactionMock as any).mockResolvedValue({ id: "tx-1" }); }); @@ -178,9 +185,15 @@ describe("proxyCleanup bot API", () => { expect.anything(), expect.objectContaining({ proposerAddress: makeBotJwtPayload().address, + txCbor: "fresh-tx-cbor", initialSignedAddresses: [], }), ); + expect(completeTxWithFreshCostModelsMock).toHaveBeenCalledWith( + getTxBuilderMock.mock.results[0]?.value, + 0, + ); + expect(completeMock).not.toHaveBeenCalled(); expect(buildProxyCleanupTxMock).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(201); expect(res.json).toHaveBeenCalledWith({ diff --git a/src/__tests__/proxySetup.bot.test.ts b/src/__tests__/proxySetup.bot.test.ts index ee796b2c..7ba631c7 100644 --- a/src/__tests__/proxySetup.bot.test.ts +++ b/src/__tests__/proxySetup.bot.test.ts @@ -15,6 +15,7 @@ const resolveCollateralRefFromChainMock: jest.Mock = jest.fn(); const resolveWalletScriptAddressMock: jest.Mock = jest.fn(); const buildProxySetupTxMock: jest.Mock = jest.fn(); const createPendingMultisigTransactionMock: jest.Mock = jest.fn(); +const completeTxWithFreshCostModelsMock: jest.Mock = jest.fn(); const completeMock: jest.Mock = jest.fn(); const getTxBuilderMock: jest.Mock = jest.fn(); @@ -67,6 +68,11 @@ jest.mock("@/lib/server/createPendingMultisigTransaction", () => ({ createPendingMultisigTransaction: createPendingMultisigTransactionMock, }), { virtual: true }); +jest.mock("@/lib/server/completeTxWithFreshCostModels", () => ({ + __esModule: true, + completeTxWithFreshCostModels: completeTxWithFreshCostModelsMock, +}), { virtual: true }); + jest.mock("@/utils/get-tx-builder", () => ({ __esModule: true, getTxBuilder: getTxBuilderMock, @@ -104,7 +110,9 @@ beforeEach(() => { paramUtxo: { txHash: "aa", outputIndex: 0 }, }); (completeMock as any).mockResolvedValue("tx-cbor"); - getTxBuilderMock.mockReturnValue({ complete: completeMock, meshTxBuilderBody: {} }); + const txBuilder = { complete: completeMock, meshTxBuilderBody: {} }; + getTxBuilderMock.mockReturnValue(txBuilder); + (completeTxWithFreshCostModelsMock as any).mockResolvedValue("fresh-tx-cbor"); (createPendingMultisigTransactionMock as any).mockResolvedValue({ id: "tx-1" }); }); @@ -161,9 +169,15 @@ describe("proxySetup bot API", () => { expect.anything(), expect.objectContaining({ proposerAddress: makeBotJwtPayload().address, + txCbor: "fresh-tx-cbor", initialSignedAddresses: [], }), ); + expect(completeTxWithFreshCostModelsMock).toHaveBeenCalledWith( + getTxBuilderMock.mock.results[0]?.value, + 0, + ); + expect(completeMock).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(201); }); }); diff --git a/src/__tests__/signTransaction.test.ts b/src/__tests__/signTransaction.test.ts index 4c3fe3f5..b80006d2 100644 --- a/src/__tests__/signTransaction.test.ts +++ b/src/__tests__/signTransaction.test.ts @@ -14,13 +14,15 @@ jest.mock( { virtual: true }, ); -const verifyJwtMock = jest.fn<(token: string | undefined) => { address: string } | null>(); +const verifyJwtMock = jest.fn<(token: string | undefined) => { address: string; botId?: string; type?: string } | null>(); +const isBotJwtMock = jest.fn<(payload: unknown) => boolean>(); jest.mock( '@/lib/verifyJwt', () => ({ __esModule: true, verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, }), { virtual: true }, ); @@ -28,6 +30,9 @@ jest.mock( const applyRateLimitMock = jest.fn< (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean >(); +const applyBotRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, botId: string) => boolean +>(); const enforceBodySizeMock = jest.fn< (req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean >(); @@ -37,6 +42,7 @@ jest.mock( () => ({ __esModule: true, applyRateLimit: applyRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, enforceBodySize: enforceBodySizeMock, }), { virtual: true }, @@ -420,7 +426,9 @@ beforeEach(() => { addCorsCacheBustingHeadersMock.mockReset(); createCallerMock.mockReset(); verifyJwtMock.mockReset(); + isBotJwtMock.mockReset(); applyRateLimitMock.mockReset(); + applyBotRateLimitMock.mockReset(); enforceBodySizeMock.mockReset(); getClientIPMock.mockReset(); @@ -432,6 +440,8 @@ beforeEach(() => { resolvePaymentKeyHashMock.mockReturnValue(witnessKeyHashHex); addressToNetworkMock.mockReturnValue(0); applyRateLimitMock.mockReturnValue(true); + applyBotRateLimitMock.mockReturnValue(true); + isBotJwtMock.mockReturnValue(false); enforceBodySizeMock.mockReturnValue(true); getClientIPMock.mockReturnValue('127.0.0.1'); shouldSubmitMultisigTxMock.mockReturnValue(true); @@ -612,6 +622,98 @@ describe('signTransaction API route', () => { }); }); + it('records witness and returns 502 when signed transaction has PPView hash mismatch', async () => { + const address = 'addr_test1qpl3w9v4l5qhxk778exampleaddress'; + const walletId = 'wallet-id-ppview'; + const transactionId = 'transaction-id-ppview'; + const signatureHex = 'aa'.repeat(64); + const keyHex = 'bb'.repeat(64); + const submissionError = + 'Transaction rejected: scriptIntegrityHash mismatch (PPViewHashesDontMatch). This transaction cannot be repaired'; + + verifyJwtMock.mockReturnValue({ address }); + + walletGetWalletMock.mockResolvedValue({ + id: walletId, + type: 'atLeast', + numRequiredSigners: 1, + signersAddresses: [address], + }); + + const transactionRecord = { + id: transactionId, + walletId, + state: 0, + signedAddresses: [] as string[], + rejectedAddresses: [] as string[], + txCbor: 'stored-tx-hex', + txHash: null as string | null, + txJson: '{}', + }; + + const updatedTransaction = { + ...transactionRecord, + signedAddresses: [address], + txCbor: 'updated-tx-hex', + state: 0, + txJson: JSON.stringify({ + multisig: { + state: 0, + submitted: false, + submissionError, + }, + }), + }; + + dbTransactionFindUniqueMock + .mockResolvedValueOnce(transactionRecord) + .mockResolvedValueOnce(updatedTransaction); + + dbTransactionUpdateManyMock.mockResolvedValue({ count: 1 }); + getProviderMock.mockReturnValue({ submitTx: jest.fn() }); + submitTxWithScriptRecoveryMock.mockRejectedValueOnce(new Error(submissionError)); + + const req = { + method: 'POST', + headers: { authorization: 'Bearer valid-token' }, + body: { + walletId, + transactionId, + address, + signature: signatureHex, + key: keyHex, + }, + } as unknown as NextApiRequest; + + const res = createMockResponse(); + + await handler(req, res); + + expect(submitTxWithScriptRecoveryMock).toHaveBeenCalledWith( + expect.objectContaining({ + txHex: 'updated-tx-hex', + }), + ); + expect(dbTransactionUpdateManyMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + signedAddresses: { set: [address] }, + txCbor: 'updated-tx-hex', + state: 0, + txJson: expect.stringContaining('PPViewHashesDontMatch'), + }), + }), + ); + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json).toHaveBeenCalledWith({ + error: 'Transaction witness recorded, but submission to network failed', + transaction: updatedTransaction, + submitted: false, + txHash: undefined, + submissionError, + }); + }); + it('returns 403 when JWT address mismatches request address', async () => { verifyJwtMock.mockReturnValue({ address: 'addr_test1qpotheraddress' }); diff --git a/src/lib/server/completeTxWithFreshCostModels.ts b/src/lib/server/completeTxWithFreshCostModels.ts new file mode 100644 index 00000000..8e9eb3ea --- /dev/null +++ b/src/lib/server/completeTxWithFreshCostModels.ts @@ -0,0 +1,180 @@ +import type { MeshTxBuilder } from "@meshsdk/core"; +import { csl } from "@meshsdk/core-csl"; + +type MeshTxBuilderWithBody = MeshTxBuilder & { + meshTxBuilderBody?: unknown; +}; + +type BlockfrostProtocolParameters = { + cost_models?: unknown; +}; + +const BLOCKFROST_BASE_URL_BY_NETWORK: Record = { + 0: "https://cardano-preprod.blockfrost.io/api/v0", + 1: "https://cardano-mainnet.blockfrost.io/api/v0", +}; + +function getBlockfrostProjectId(network: number): string { + const projectId = + network === 0 + ? process.env.CI_BLOCKFROST_PREPROD_API_KEY?.trim() || + process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD?.trim() + : process.env.CI_BLOCKFROST_MAINNET_API_KEY?.trim() || + process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET?.trim(); + if (!projectId) { + throw new Error(`Missing Blockfrost project id for network ${network}`); + } + return projectId; +} + +function getBlockfrostBaseUrl(network: number): string { + const baseUrl = BLOCKFROST_BASE_URL_BY_NETWORK[network]; + if (!baseUrl) { + throw new Error(`Unsupported Cardano network id ${network}`); + } + return baseUrl; +} + +async function fetchLatestCostModels(network: number): Promise { + const response = await fetch(`${getBlockfrostBaseUrl(network)}/epochs/latest/parameters`, { + headers: { + project_id: getBlockfrostProjectId(network), + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `Failed to fetch latest Blockfrost protocol parameters (${response.status}): ${body}`, + ); + } + + const parameters = (await response.json()) as BlockfrostProtocolParameters; + if (!parameters.cost_models || typeof parameters.cost_models !== "object") { + throw new Error("Latest Blockfrost protocol parameters did not include cost_models"); + } + return parameters.cost_models; +} + +function normalizeCostModelValues(value: unknown): number[] { + if (Array.isArray(value)) { + return value.map((entry) => Number(entry)); + } + if (value && typeof value === "object") { + return Object.values(value as Record).map((entry) => Number(entry)); + } + throw new Error("Invalid Blockfrost cost model shape"); +} + +function toCostModel(value: unknown): csl.CostModel { + const costModel = csl.CostModel.new(); + normalizeCostModelValues(value).forEach((cost, index) => { + if (!Number.isInteger(cost)) { + throw new Error(`Invalid cost model value at index ${index}`); + } + costModel.set(index, csl.Int.new_i32(cost)); + }); + return costModel; +} + +function findCostModel( + costModels: Record, + language: "PlutusV1" | "PlutusV2" | "PlutusV3", +): unknown { + const aliases: Record = { + PlutusV1: ["PlutusV1", "plutus:v1", "V1", "0"], + PlutusV2: ["PlutusV2", "plutus:v2", "V2", "1"], + PlutusV3: ["PlutusV3", "plutus:v3", "V3", "2"], + }; + + for (const alias of aliases[language]) { + if (costModels[alias]) return costModels[alias]; + } + + throw new Error(`Latest Blockfrost protocol parameters did not include ${language} cost model`); +} + +function collectPlutusLanguages(builderBody: unknown): Set<"V1" | "V2" | "V3"> { + const languages = new Set<"V1" | "V2" | "V3">(); + + const visit = (value: unknown) => { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + + const record = value as Record; + const version = record.version; + if (version === "V1" || version === "V2" || version === "V3") { + languages.add(version); + } + + for (const child of Object.values(record)) { + visit(child); + } + }; + + visit(builderBody); + return languages; +} + +function toCostmdls( + costModels: unknown, + languages: Set<"V1" | "V2" | "V3">, +): csl.Costmdls { + const rawCostModels = costModels as Record; + const costmdls = csl.Costmdls.new(); + + if (languages.has("V1")) { + costmdls.insert(csl.Language.new_plutus_v1(), toCostModel(findCostModel(rawCostModels, "PlutusV1"))); + } + if (languages.has("V2")) { + costmdls.insert(csl.Language.new_plutus_v2(), toCostModel(findCostModel(rawCostModels, "PlutusV2"))); + } + if (languages.has("V3")) { + costmdls.insert(csl.Language.new_plutus_v3(), toCostModel(findCostModel(rawCostModels, "PlutusV3"))); + } + + return costmdls; +} + +export function refreshScriptDataHash( + txHex: string, + costModels: unknown, + builderBody: unknown, +): string { + const tx = csl.Transaction.from_hex(txHex); + const witnessSet = tx.witness_set(); + const redeemers = witnessSet.redeemers(); + if (!redeemers || redeemers.len() === 0) { + return txHex; + } + + const languages = collectPlutusLanguages(builderBody); + if (languages.size === 0) { + return txHex; + } + + const scriptDataHash = csl.hash_script_data( + redeemers, + toCostmdls(costModels, languages), + witnessSet.plutus_data(), + ); + + const body = tx.body(); + body.set_script_data_hash(scriptDataHash); + + const updatedTx = csl.Transaction.new(body, witnessSet, tx.auxiliary_data()); + updatedTx.set_is_valid(tx.is_valid()); + return updatedTx.to_hex(); +} + +export async function completeTxWithFreshCostModels( + txBuilder: MeshTxBuilderWithBody, + network: number, +): Promise { + const txHex = await txBuilder.complete(); + const costModels = await fetchLatestCostModels(network); + return refreshScriptDataHash(txHex, costModels, txBuilder.meshTxBuilderBody); +} diff --git a/src/pages/api/v1/proxyCleanup.ts b/src/pages/api/v1/proxyCleanup.ts index 7b5a8a70..12b23b1f 100644 --- a/src/pages/api/v1/proxyCleanup.ts +++ b/src/pages/api/v1/proxyCleanup.ts @@ -19,6 +19,7 @@ import { type UtxoRef, } from "@/lib/server/proxyUtxos"; import { createPendingMultisigTransaction } from "@/lib/server/createPendingMultisigTransaction"; +import { completeTxWithFreshCostModels } from "@/lib/server/completeTxWithFreshCostModels"; import { getProvider } from "@/utils/get-provider"; import { getTxBuilder } from "@/utils/get-tx-builder"; import { @@ -226,7 +227,7 @@ export default async function handler( return res.status(proxyUtxosResult.status).json({ error: proxyUtxosResult.error }); } - const txBuilder = getTxBuilder(network) as MeshTxBuilderWithBody; + const txBuilder = getTxBuilder(network, true) as MeshTxBuilderWithBody; let cleanup: CleanupMetadata; try { if (proxyUtxosResult.utxos.length > 0) { @@ -275,7 +276,7 @@ export default async function handler( let txCbor: string; try { - txCbor = await txBuilder.complete(); + txCbor = await completeTxWithFreshCostModels(txBuilder, network); } catch (error) { console.error("proxyCleanup complete error:", error); return res.status(500).json({ diff --git a/src/pages/api/v1/proxyDRepCertificate.ts b/src/pages/api/v1/proxyDRepCertificate.ts index ef2b3027..da132550 100644 --- a/src/pages/api/v1/proxyDRepCertificate.ts +++ b/src/pages/api/v1/proxyDRepCertificate.ts @@ -17,6 +17,7 @@ import { type UtxoRef, } from "@/lib/server/proxyUtxos"; import { createPendingMultisigTransaction } from "@/lib/server/createPendingMultisigTransaction"; +import { completeTxWithFreshCostModels } from "@/lib/server/completeTxWithFreshCostModels"; import { getTxBuilder } from "@/utils/get-tx-builder"; import { buildProxyDRepCertificateTx, @@ -187,7 +188,7 @@ export default async function handler( return res.status(resolvedCollateral.status).json({ error: resolvedCollateral.error }); } - const txBuilder = getTxBuilder(network) as MeshTxBuilderWithBody; + const txBuilder = getTxBuilder(network, true) as MeshTxBuilderWithBody; let details: { dRepId: string; anchorDataHash?: string }; try { details = buildProxyDRepCertificateTx({ @@ -211,7 +212,7 @@ export default async function handler( let txCbor: string; try { - txCbor = await txBuilder.complete(); + txCbor = await completeTxWithFreshCostModels(txBuilder, network); } catch (error) { console.error("proxyDRepCertificate complete error:", error); return res.status(500).json({ diff --git a/src/pages/api/v1/proxySetup.ts b/src/pages/api/v1/proxySetup.ts index 837922c5..43325341 100644 --- a/src/pages/api/v1/proxySetup.ts +++ b/src/pages/api/v1/proxySetup.ts @@ -12,6 +12,7 @@ import { resolveWalletScriptAddress } from "@/lib/server/walletScriptAddress"; import { resolveUtxoRefsFromChain } from "@/lib/server/resolveUtxoRefsFromChain"; import { resolveCollateralRefFromChain, type UtxoRef } from "@/lib/server/proxyUtxos"; import { createPendingMultisigTransaction } from "@/lib/server/createPendingMultisigTransaction"; +import { completeTxWithFreshCostModels } from "@/lib/server/completeTxWithFreshCostModels"; import { getTxBuilder } from "@/utils/get-tx-builder"; import { buildProxySetupTx, @@ -156,7 +157,7 @@ export default async function handler( .json({ error: resolvedCollateral.error }); } - const txBuilder = getTxBuilder(network) as MeshTxBuilderWithBody; + const txBuilder = getTxBuilder(network, true) as MeshTxBuilderWithBody; let setup; try { setup = buildProxySetupTx({ @@ -176,7 +177,7 @@ export default async function handler( let txCbor: string; try { - txCbor = await txBuilder.complete(); + txCbor = await completeTxWithFreshCostModels(txBuilder, network); } catch (error) { console.error("proxySetup complete error:", error); return res.status(500).json({ diff --git a/src/pages/api/v1/proxySpend.ts b/src/pages/api/v1/proxySpend.ts index ca8aeaeb..6a291851 100644 --- a/src/pages/api/v1/proxySpend.ts +++ b/src/pages/api/v1/proxySpend.ts @@ -20,6 +20,7 @@ import { type UtxoRef, } from "@/lib/server/proxyUtxos"; import { createPendingMultisigTransaction } from "@/lib/server/createPendingMultisigTransaction"; +import { completeTxWithFreshCostModels } from "@/lib/server/completeTxWithFreshCostModels"; import { getProvider } from "@/utils/get-provider"; import { getTxBuilder } from "@/utils/get-tx-builder"; import { buildProxySpendTx, deriveProxyScripts } from "@/lib/server/proxyTxBuilders"; @@ -254,7 +255,7 @@ export default async function handler( return res.status(proxyUtxos.status).json({ error: proxyUtxos.error }); } - const txBuilder = getTxBuilder(network) as MeshTxBuilderWithBody; + const txBuilder = getTxBuilder(network, true) as MeshTxBuilderWithBody; try { buildProxySpendTx({ txBuilder, @@ -277,7 +278,7 @@ export default async function handler( let txCbor: string; try { - txCbor = await txBuilder.complete(); + txCbor = await completeTxWithFreshCostModels(txBuilder, network); } catch (error) { console.error("proxySpend complete error:", error); return res.status(500).json({ diff --git a/src/pages/api/v1/proxyVote.ts b/src/pages/api/v1/proxyVote.ts index 684d491a..5945b429 100644 --- a/src/pages/api/v1/proxyVote.ts +++ b/src/pages/api/v1/proxyVote.ts @@ -17,6 +17,7 @@ import { type UtxoRef, } from "@/lib/server/proxyUtxos"; import { createPendingMultisigTransaction } from "@/lib/server/createPendingMultisigTransaction"; +import { completeTxWithFreshCostModels } from "@/lib/server/completeTxWithFreshCostModels"; import { getTxBuilder } from "@/utils/get-tx-builder"; import { buildProxyVoteTx, @@ -210,7 +211,7 @@ export default async function handler( return res.status(resolvedCollateral.status).json({ error: resolvedCollateral.error }); } - const txBuilder = getTxBuilder(network) as MeshTxBuilderWithBody; + const txBuilder = getTxBuilder(network, true) as MeshTxBuilderWithBody; let details: { dRepId: string }; try { details = buildProxyVoteTx({ @@ -232,7 +233,7 @@ export default async function handler( let txCbor: string; try { - txCbor = await txBuilder.complete(); + txCbor = await completeTxWithFreshCostModels(txBuilder, network); } catch (error) { console.error("proxyVote complete error:", error); return res.status(500).json({ diff --git a/src/utils/get-tx-builder.ts b/src/utils/get-tx-builder.ts index 7c956f25..dfb7e08b 100644 --- a/src/utils/get-tx-builder.ts +++ b/src/utils/get-tx-builder.ts @@ -1,13 +1,13 @@ import { MeshTxBuilder } from "@meshsdk/core"; +import { CSLSerializer } from "@meshsdk/core-csl"; import { getProvider } from "@/utils/get-provider"; -// import { CSLSerializer } from "@meshsdk/core-csl"; -export function getTxBuilder(network: number) { +export function getTxBuilder(network: number, useCslSerializer = false) { const blockchainProvider = getProvider(network); const txBuilder = new MeshTxBuilder({ fetcher: blockchainProvider, evaluator: blockchainProvider, - // serializer: new CSLSerializer(), + ...(useCslSerializer ? { serializer: new CSLSerializer() } : {}), verbose: true, }); if (network === 1) { diff --git a/src/utils/txScriptRecovery.ts b/src/utils/txScriptRecovery.ts index 78d1339e..d15ef67b 100644 --- a/src/utils/txScriptRecovery.ts +++ b/src/utils/txScriptRecovery.ts @@ -192,6 +192,10 @@ function hasInvalidWitnessFailure(error: unknown): boolean { return extractErrorMessage(error).includes("InvalidWitnessesUTXOW"); } +function hasPPViewHashMismatch(error: unknown): boolean { + return extractErrorMessage(error).includes("PPViewHashesDontMatch"); +} + function extractInvalidWitnessVKeys(error: unknown): string[] { const message = extractErrorMessage(error); const markerIndex = message.indexOf("InvalidWitnessesUTXOW"); @@ -501,6 +505,15 @@ export async function submitTxWithScriptRecovery({ } catch (submitError) { throwIfUnrecoverableSubmitError(submitError); + if (hasPPViewHashMismatch(submitError)) { + throw new Error( + "Transaction rejected: scriptIntegrityHash mismatch (PPViewHashesDontMatch). " + + "The Plutus V3 cost model used at build time does not match the current node. " + + "This transaction cannot be repaired — it must be rebuilt. " + + "Original error: " + String(submitError), + ); + } + if (hasInvalidWitnessFailure(submitError)) { const invalidVKeys = extractInvalidWitnessVKeys(submitError); if (invalidVKeys.length > 0) { From 98f1edec35bca3bf2efc839b6b4cbaddca87a5c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Wed, 20 May 2026 11:32:57 +0200 Subject: [PATCH 3/5] feat: add TRPC test command and update unit test workflow - Added a new test command for TRPC in package.json to facilitate targeted testing of TRPC-related components. - Updated the unit test workflow to correct the test path pattern for transaction builder tests, ensuring accurate test execution. - Removed unused cborUtils.ts file to clean up the test directory and improve maintainability. - Simplified infrastructure tests by removing unnecessary cbor-x decoding tests. --- .github/workflows/trpc-integration-tests.yml | 56 ++++++ .github/workflows/unit-tests.yml | 2 +- package.json | 1 + src/__tests__/trpc/createProxy.test.ts | 179 ++++++++++++++++++ src/__tests__/trpc/createTransaction.test.ts | 165 ++++++++++++++++ src/__tests__/trpc/fixtures.ts | 66 +++++++ src/__tests__/trpc/helpers.ts | 63 ++++++ .../trpc/pendingTransactions.test.ts | 118 ++++++++++++ src/__tests__/trpc/proxyAuth.test.ts | 147 ++++++++++++++ src/__tests__/trpc/transactionAuth.test.ts | 143 ++++++++++++++ src/__tests__/tx-builders/cborUtils.ts | 33 ---- .../tx-builders/infrastructure.test.ts | 7 - src/server/api/routers/transactions.ts | 1 + 13 files changed, 940 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/trpc-integration-tests.yml create mode 100644 src/__tests__/trpc/createProxy.test.ts create mode 100644 src/__tests__/trpc/createTransaction.test.ts create mode 100644 src/__tests__/trpc/fixtures.ts create mode 100644 src/__tests__/trpc/helpers.ts create mode 100644 src/__tests__/trpc/pendingTransactions.test.ts create mode 100644 src/__tests__/trpc/proxyAuth.test.ts create mode 100644 src/__tests__/trpc/transactionAuth.test.ts delete mode 100644 src/__tests__/tx-builders/cborUtils.ts diff --git a/.github/workflows/trpc-integration-tests.yml b/.github/workflows/trpc-integration-tests.yml new file mode 100644 index 00000000..d400ad5e --- /dev/null +++ b/.github/workflows/trpc-integration-tests.yml @@ -0,0 +1,56 @@ +name: tRPC Integration Tests + +on: + pull_request: + branches: + - main + - preprod + push: + branches: + - main + workflow_dispatch: + +jobs: + trpc-tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: multisig_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + NODE_ENV: test + SKIP_ENV_VALIDATION: "true" + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/multisig_test + DIRECT_URL: postgresql://postgres:postgres@localhost:5432/multisig_test + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run database migrations + run: npx prisma migrate deploy + + - name: Run tRPC integration tests + run: npm run test:trpc diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index dbda52e9..e515b6aa 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -29,7 +29,7 @@ jobs: run: npm ci - name: Run transaction builder tests - run: npm run test:ci -- --testPathPattern="src/__tests__/tx-builders" + run: npm run test:ci -- --testPathPatterns="src/__tests__/tx-builders" - name: Upload coverage report if: always() diff --git a/package.json b/package.json index c22df500..649308fb 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:ci": "jest --ci --coverage --watchAll=false", + "test:trpc": "jest --testPathPatterns=\"src/__tests__/trpc\" --runInBand", "analyze": "ANALYZE=true npm run build", "apply-project": "node scripts/apply-project-to-github.mjs" }, diff --git a/src/__tests__/trpc/createProxy.test.ts b/src/__tests__/trpc/createProxy.test.ts new file mode 100644 index 00000000..46ae7253 --- /dev/null +++ b/src/__tests__/trpc/createProxy.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeAll, describe, expect, it, jest } from "@jest/globals"; + +import { realTestAddresses } from "../testUtils"; +import { cleanupFixtures, seedUser, seedWallet } from "./fixtures"; +import { makeSessionCtx, makeWalletCtx } from "./helpers"; + +jest.mock("@/env", () => ({ + __esModule: true, + env: { + DATABASE_URL: process.env.DATABASE_URL, + DIRECT_URL: process.env.DIRECT_URL, + NODE_ENV: "test", + }, +}), { virtual: true }); + +jest.mock("superjson", () => ({ + __esModule: true, + default: { + serialize: (value: unknown) => value, + deserialize: (value: unknown) => value, + }, +})); + +jest.mock("@/server/auth", () => ({ + __esModule: true, + getServerAuthSession: jest.fn(), +})); + +const HAVE_DB = !!process.env.DATABASE_URL; +const describeWithDb = HAVE_DB ? describe : describe.skip; + +let createCaller: typeof import("@/server/api/root").createCaller; +let db: typeof import("@/server/db").db; +let walletIds: string[] = []; +let userIds: string[] = []; + +const SIGNER = realTestAddresses.address1; +const USER_ADDR = realTestAddresses.address2; + +const proxyInput = { + proxyAddress: "addr_test1proxy", + authTokenId: "auth-token-1", + paramUtxo: "txhash#0", +}; + +describeWithDb("proxy.createProxy", () => { + beforeAll(async () => { + ({ createCaller } = await import("@/server/api/root")); + ({ db } = await import("@/server/db")); + }); + + afterEach(async () => { + for (const walletId of walletIds) { + await cleanupFixtures(db, { walletId }); + } + for (const userId of userIds) { + await cleanupFixtures(db, { userId }); + } + walletIds = []; + userIds = []; + }); + + async function seedWalletCaller(address = SIGNER) { + const seeded = await seedWallet(db, SIGNER); + walletIds.push(seeded.walletId); + return { + walletId: seeded.walletId, + caller: createCaller(makeWalletCtx(address, db) as any), + }; + } + + async function seedUserCaller(address = USER_ADDR) { + const seeded = await seedUser(db, address); + userIds.push(seeded.userId); + return { + userId: seeded.userId, + caller: createCaller(makeSessionCtx(address, db) as any), + }; + } + + it("creates an active wallet-owned proxy", async () => { + const { caller, walletId } = await seedWalletCaller(); + + const result = await caller.proxy.createProxy({ + walletId, + ...proxyInput, + }); + + expect(result).toMatchObject({ + walletId, + userId: null, + proxyAddress: proxyInput.proxyAddress, + authTokenId: proxyInput.authTokenId, + paramUtxo: proxyInput.paramUtxo, + isActive: true, + }); + }); + + it("creates an active user-owned proxy", async () => { + const { caller, userId } = await seedUserCaller(); + + const result = await caller.proxy.createProxy({ + userId, + ...proxyInput, + }); + + expect(result).toMatchObject({ + walletId: null, + userId, + isActive: true, + }); + }); + + it("defaults isActive to true", async () => { + const { caller, walletId } = await seedWalletCaller(); + + const result = await caller.proxy.createProxy({ + walletId, + ...proxyInput, + }); + + expect(result.isActive).toBe(true); + }); + + it("persists an optional description", async () => { + const { caller, walletId } = await seedWalletCaller(); + + const result = await caller.proxy.createProxy({ + walletId, + ...proxyInput, + description: "bot", + }); + + expect(result.description).toBe("bot"); + }); + + it("rejects input with neither walletId nor userId", async () => { + const { caller } = await seedWalletCaller(); + + await expect(caller.proxy.createProxy(proxyInput)).rejects.toBeInstanceOf(Error); + }); + + it("throws FORBIDDEN when caller is not a wallet signer", async () => { + const { caller, walletId } = await seedWalletCaller(USER_ADDR); + + await expect( + caller.proxy.createProxy({ + walletId, + ...proxyInput, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("throws FORBIDDEN when caller is a different user", async () => { + const { userId } = await seedUserCaller(USER_ADDR); + const other = await seedUser(db, SIGNER); + userIds.push(other.userId); + const caller = createCaller(makeSessionCtx(SIGNER, db) as any); + + await expect( + caller.proxy.createProxy({ + userId, + ...proxyInput, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("returns created wallet proxies through getProxiesByWallet", async () => { + const { caller, walletId } = await seedWalletCaller(); + const created = await caller.proxy.createProxy({ + walletId, + ...proxyInput, + }); + + const result = await caller.proxy.getProxiesByWallet({ walletId }); + + expect(result.map((proxy) => proxy.id)).toContain(created.id); + }); +}); diff --git a/src/__tests__/trpc/createTransaction.test.ts b/src/__tests__/trpc/createTransaction.test.ts new file mode 100644 index 00000000..30653596 --- /dev/null +++ b/src/__tests__/trpc/createTransaction.test.ts @@ -0,0 +1,165 @@ +import { beforeAll, afterEach, describe, expect, it, jest } from "@jest/globals"; + +import { realTestAddresses } from "../testUtils"; +import { cleanupFixtures, seedWallet } from "./fixtures"; +import { makeWalletCtx } from "./helpers"; + +jest.mock("@/env", () => ({ + __esModule: true, + env: { + DATABASE_URL: process.env.DATABASE_URL, + DIRECT_URL: process.env.DIRECT_URL, + NODE_ENV: "test", + }, +}), { virtual: true }); + +jest.mock("superjson", () => ({ + __esModule: true, + default: { + serialize: (value: unknown) => value, + deserialize: (value: unknown) => value, + }, +})); + +jest.mock("@/server/auth", () => ({ + __esModule: true, + getServerAuthSession: jest.fn(), +})); + +const HAVE_DB = !!process.env.DATABASE_URL; +const describeWithDb = HAVE_DB ? describe : describe.skip; + +let createCaller: typeof import("@/server/api/root").createCaller; +let db: typeof import("@/server/db").db; +let walletId: string | undefined; + +const SIGNER = realTestAddresses.address1; + +const baseInput = () => ({ + walletId: walletId!, + txJson: JSON.stringify({ body: {} }), + signedAddresses: [] as string[], + txCbor: "deadbeef", + state: 0, +}); + +describeWithDb("transaction.createTransaction", () => { + beforeAll(async () => { + ({ createCaller } = await import("@/server/api/root")); + ({ db } = await import("@/server/db")); + }); + + afterEach(async () => { + if (walletId) { + await cleanupFixtures(db, { walletId }); + walletId = undefined; + } + }); + + async function seedCaller(address = SIGNER) { + ({ walletId } = await seedWallet(db, SIGNER)); + return createCaller(makeWalletCtx(address, db) as any); + } + + it("creates an unsigned pending transaction for a signer", async () => { + const caller = await seedCaller(); + + const result = await caller.transaction.createTransaction(baseInput()); + + expect(result).toMatchObject({ + walletId, + txJson: JSON.stringify({ body: {} }), + txCbor: "deadbeef", + signedAddresses: [], + rejectedAddresses: [], + state: 0, + }); + expect(result.id).toBeTruthy(); + expect(result.createdAt).toBeInstanceOf(Date); + expect(result.updatedAt).toBeInstanceOf(Date); + + const persisted = await db.transaction.findUnique({ where: { id: result.id } }); + expect(persisted).toMatchObject({ + id: result.id, + walletId, + state: 0, + rejectedAddresses: [], + }); + }); + + it("persists an optional description", async () => { + const caller = await seedCaller(); + + const result = await caller.transaction.createTransaction({ + ...baseInput(), + description: "my desc", + }); + + expect(result.description).toBe("my desc"); + }); + + it("persists an optional transaction hash", async () => { + const caller = await seedCaller(); + + const result = await caller.transaction.createTransaction({ + ...baseInput(), + txHash: "abc123", + }); + + expect(result.txHash).toBe("abc123"); + }); + + it("rejects an empty txCbor before writing to the database", async () => { + const caller = await seedCaller(); + + await expect( + caller.transaction.createTransaction({ + ...baseInput(), + txCbor: "", + }), + ).rejects.toBeInstanceOf(Error); + + await expect(db.transaction.findMany({ where: { walletId } })).resolves.toHaveLength(0); + }); + + it("rejects an empty txJson before writing to the database", async () => { + const caller = await seedCaller(); + + await expect( + caller.transaction.createTransaction({ + ...baseInput(), + txJson: "", + }), + ).rejects.toBeInstanceOf(Error); + + await expect(db.transaction.findMany({ where: { walletId } })).resolves.toHaveLength(0); + }); + + it("throws FORBIDDEN for a non-signer caller", async () => { + const caller = await seedCaller(realTestAddresses.address2); + + await expect(caller.transaction.createTransaction(baseInput())).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("returns the full persisted row shape", async () => { + const caller = await seedCaller(); + + const result = await caller.transaction.createTransaction(baseInput()); + + expect(result).toEqual( + expect.objectContaining({ + id: expect.any(String), + walletId, + txCbor: expect.any(String), + txJson: expect.any(String), + signedAddresses: expect.any(Array), + rejectedAddresses: expect.any(Array), + state: expect.any(Number), + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }), + ); + }); +}); diff --git a/src/__tests__/trpc/fixtures.ts b/src/__tests__/trpc/fixtures.ts new file mode 100644 index 00000000..4134e3aa --- /dev/null +++ b/src/__tests__/trpc/fixtures.ts @@ -0,0 +1,66 @@ +import { randomUUID } from "crypto"; +import type { PrismaClient } from "@prisma/client"; + +type DbClient = PrismaClient; + +export async function seedWallet(db: DbClient, signerAddress: string): Promise<{ walletId: string }> { + const suffix = randomUUID().replace(/-/g, "").slice(0, 12); + const wallet = await db.wallet.create({ + data: { + name: `trpc-wallet-${suffix}`, + description: `tRPC test wallet ${suffix}`, + signersAddresses: [signerAddress], + signersStakeKeys: [], + signersDRepKeys: [], + signersDescriptions: [""], + numRequiredSigners: 1, + verified: [], + scriptCbor: "deadbeef", + stakeCredentialHash: null, + type: "atLeast", + ownerAddress: signerAddress, + }, + }); + + return { walletId: wallet.id }; +} + +export async function seedUser(db: DbClient, address: string): Promise<{ userId: string }> { + const suffix = randomUUID().replace(/-/g, "").slice(0, 12); + const user = await db.user.create({ + data: { + address, + stakeAddress: `stake_test_${suffix}`, + nostrKey: `nostr_${suffix}`, + }, + }); + + return { userId: user.id }; +} + +export async function cleanupFixtures( + db: DbClient, + ids: { walletId?: string; userId?: string }, +): Promise { + try { + if (ids.walletId) { + await db.transaction.deleteMany({ where: { walletId: ids.walletId } }); + await db.proxy.deleteMany({ where: { walletId: ids.walletId } }); + await db.walletBotAccess.deleteMany({ where: { walletId: ids.walletId } }); + } + + if (ids.userId) { + await db.proxy.deleteMany({ where: { userId: ids.userId } }); + } + + if (ids.walletId) { + await db.wallet.deleteMany({ where: { id: ids.walletId } }); + } + + if (ids.userId) { + await db.user.deleteMany({ where: { id: ids.userId } }); + } + } catch { + // Cleanup should not mask the original test failure. + } +} diff --git a/src/__tests__/trpc/helpers.ts b/src/__tests__/trpc/helpers.ts new file mode 100644 index 00000000..b34c0c09 --- /dev/null +++ b/src/__tests__/trpc/helpers.ts @@ -0,0 +1,63 @@ +import type { PrismaClient } from "@prisma/client"; +import type { Session } from "next-auth"; + +type CallerDb = PrismaClient | Record; + +export type CallerContext = { + db: CallerDb; + session: Session | null; + sessionAddress: string | null; + sessionWallets: string[]; + primaryWallet: string | null; + ip: string; +}; + +let ipCounter = 1; + +function nextTestIp() { + const value = ipCounter; + ipCounter += 1; + return `198.51.100.${value}`; +} + +export function makeWalletCtx( + signerAddress: string, + db: CallerDb = undefined as unknown as CallerDb, +): CallerContext { + return { + db, + session: null, + sessionAddress: signerAddress, + sessionWallets: [signerAddress], + primaryWallet: signerAddress, + ip: nextTestIp(), + }; +} + +export function makeSessionCtx( + userAddress: string, + db: CallerDb = undefined as unknown as CallerDb, +): CallerContext { + return { + db, + session: { + user: { id: userAddress }, + expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + } as Session, + sessionAddress: userAddress, + sessionWallets: [], + primaryWallet: null, + ip: nextTestIp(), + }; +} + +export function makeAnonymousCtx(db: CallerDb = undefined as unknown as CallerDb): CallerContext { + return { + db, + session: null, + sessionAddress: null, + sessionWallets: [], + primaryWallet: null, + ip: nextTestIp(), + }; +} diff --git a/src/__tests__/trpc/pendingTransactions.test.ts b/src/__tests__/trpc/pendingTransactions.test.ts new file mode 100644 index 00000000..9e9c0827 --- /dev/null +++ b/src/__tests__/trpc/pendingTransactions.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeAll, describe, expect, it, jest } from "@jest/globals"; + +import { realTestAddresses } from "../testUtils"; +import { cleanupFixtures, seedWallet } from "./fixtures"; +import { makeWalletCtx } from "./helpers"; + +jest.mock("@/env", () => ({ + __esModule: true, + env: { + DATABASE_URL: process.env.DATABASE_URL, + DIRECT_URL: process.env.DIRECT_URL, + NODE_ENV: "test", + }, +}), { virtual: true }); + +jest.mock("superjson", () => ({ + __esModule: true, + default: { + serialize: (value: unknown) => value, + deserialize: (value: unknown) => value, + }, +})); + +jest.mock("@/server/auth", () => ({ + __esModule: true, + getServerAuthSession: jest.fn(), +})); + +const HAVE_DB = !!process.env.DATABASE_URL; +const describeWithDb = HAVE_DB ? describe : describe.skip; + +let createCaller: typeof import("@/server/api/root").createCaller; +let db: typeof import("@/server/db").db; +let walletIds: string[] = []; + +const SIGNER = realTestAddresses.address1; + +describeWithDb("transaction.getPendingTransactions", () => { + beforeAll(async () => { + ({ createCaller } = await import("@/server/api/root")); + ({ db } = await import("@/server/db")); + }); + + afterEach(async () => { + for (const walletId of walletIds) { + await cleanupFixtures(db, { walletId }); + } + walletIds = []; + }); + + async function seedCaller() { + const seeded = await seedWallet(db, SIGNER); + walletIds.push(seeded.walletId); + return { + walletId: seeded.walletId, + caller: createCaller(makeWalletCtx(SIGNER, db) as any), + }; + } + + async function createTransaction(walletId: string, state: number, txCbor = "deadbeef") { + return db.transaction.create({ + data: { + walletId, + txJson: JSON.stringify({ body: { state } }), + txCbor, + signedAddresses: [], + rejectedAddresses: [], + state, + }, + }); + } + + it("returns an empty array when there are no pending transactions", async () => { + const { caller, walletId } = await seedCaller(); + + await expect(caller.transaction.getPendingTransactions({ walletId })).resolves.toEqual([]); + }); + + it("returns a pending state-0 transaction", async () => { + const { caller, walletId } = await seedCaller(); + const tx = await createTransaction(walletId, 0); + + const result = await caller.transaction.getPendingTransactions({ walletId }); + + expect(result).toHaveLength(1); + expect(result[0]?.id).toBe(tx.id); + }); + + it("does not return a completed state-1 transaction", async () => { + const { caller, walletId } = await seedCaller(); + await createTransaction(walletId, 1); + + await expect(caller.transaction.getPendingTransactions({ walletId })).resolves.toEqual([]); + }); + + it("orders multiple pending transactions by createdAt descending", async () => { + const { caller, walletId } = await seedCaller(); + const older = await createTransaction(walletId, 0, "aa"); + await new Promise((resolve) => setTimeout(resolve, 20)); + const newer = await createTransaction(walletId, 0, "bb"); + + const result = await caller.transaction.getPendingTransactions({ walletId }); + + expect(result.map((tx) => tx.id)).toEqual([newer.id, older.id]); + }); + + it("does not return transactions from another wallet", async () => { + const { caller, walletId } = await seedCaller(); + const other = await seedWallet(db, SIGNER); + walletIds.push(other.walletId); + const ownTx = await createTransaction(walletId, 0, "aa"); + await createTransaction(other.walletId, 0, "bb"); + + const result = await caller.transaction.getPendingTransactions({ walletId }); + + expect(result.map((tx) => tx.id)).toEqual([ownTx.id]); + }); +}); diff --git a/src/__tests__/trpc/proxyAuth.test.ts b/src/__tests__/trpc/proxyAuth.test.ts new file mode 100644 index 00000000..a7db9373 --- /dev/null +++ b/src/__tests__/trpc/proxyAuth.test.ts @@ -0,0 +1,147 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; + +import { makeAnonymousCtx, makeSessionCtx, makeWalletCtx } from "./helpers"; + +jest.mock("@/env", () => ({ + __esModule: true, + env: { + DATABASE_URL: process.env.DATABASE_URL, + DIRECT_URL: process.env.DIRECT_URL, + NODE_ENV: "test", + }, +}), { virtual: true }); + +jest.mock("superjson", () => ({ + __esModule: true, + default: { + serialize: (value: unknown) => value, + deserialize: (value: unknown) => value, + }, +})); + +jest.mock("@/server/auth", () => ({ + __esModule: true, + getServerAuthSession: jest.fn(), +})); + +let createCaller: typeof import("@/server/api/root").createCaller; + +const makeMockDb = () => ({ + wallet: { findUnique: jest.fn() }, + proxy: { + create: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + }, + user: { findUnique: jest.fn() }, +}); + +const proxyInput = { + proxyAddress: "addr_test1proxy", + authTokenId: "token-1", + paramUtxo: "txhash#0", +}; + +describe("proxy router authorization", () => { + beforeAll(async () => { + ({ createCaller } = await import("@/server/api/root")); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("throws UNAUTHORIZED when session is missing", async () => { + const mockDb = makeMockDb(); + const caller = createCaller(makeAnonymousCtx(mockDb) as any); + + await expect( + caller.proxy.createProxy({ + walletId: "wallet-1", + ...proxyInput, + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(mockDb.proxy.create).not.toHaveBeenCalled(); + }); + + it("throws FORBIDDEN when wallet caller is not a signer", async () => { + const mockDb = makeMockDb(); + mockDb.wallet.findUnique.mockResolvedValueOnce({ + id: "wallet-1", + signersAddresses: ["addr_signer"], + ownerAddress: "addr_outsider", + } as never); + const caller = createCaller(makeWalletCtx("addr_outsider", mockDb) as any); + + await expect( + caller.proxy.createProxy({ + walletId: "wallet-1", + ...proxyInput, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(mockDb.proxy.create).not.toHaveBeenCalled(); + }); + + it("throws NOT_FOUND when userId is given but user does not exist", async () => { + const mockDb = makeMockDb(); + mockDb.user.findUnique.mockResolvedValueOnce(null as never); + const caller = createCaller(makeSessionCtx("addr_user", mockDb) as any); + + await expect( + caller.proxy.createProxy({ + userId: "user-1", + ...proxyInput, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + expect(mockDb.proxy.create).not.toHaveBeenCalled(); + }); + + it("throws FORBIDDEN when userId belongs to another user", async () => { + const mockDb = makeMockDb(); + mockDb.user.findUnique.mockResolvedValueOnce({ id: "different-user" } as never); + const caller = createCaller(makeSessionCtx("addr_user", mockDb) as any); + + await expect( + caller.proxy.createProxy({ + userId: "user-1", + ...proxyInput, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(mockDb.proxy.create).not.toHaveBeenCalled(); + }); + + it("allows walletId when caller is a signer", async () => { + const mockDb = makeMockDb(); + mockDb.wallet.findUnique.mockResolvedValueOnce({ + id: "wallet-1", + signersAddresses: ["addr_signer"], + } as never); + mockDb.proxy.create.mockResolvedValueOnce({ + id: "proxy-1", + walletId: "wallet-1", + isActive: true, + } as never); + const caller = createCaller(makeWalletCtx("addr_signer", mockDb) as any); + + await expect( + caller.proxy.createProxy({ + walletId: "wallet-1", + ...proxyInput, + }), + ).resolves.toMatchObject({ id: "proxy-1", walletId: "wallet-1" }); + }); + + it("rejects input with neither walletId nor userId", async () => { + const mockDb = makeMockDb(); + const caller = createCaller(makeWalletCtx("addr_signer", mockDb) as any); + + await expect(caller.proxy.createProxy(proxyInput)).rejects.toBeInstanceOf(Error); + + expect(mockDb.wallet.findUnique).not.toHaveBeenCalled(); + expect(mockDb.proxy.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/trpc/transactionAuth.test.ts b/src/__tests__/trpc/transactionAuth.test.ts new file mode 100644 index 00000000..02737c93 --- /dev/null +++ b/src/__tests__/trpc/transactionAuth.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, jest, beforeAll, beforeEach } from "@jest/globals"; + +import { makeAnonymousCtx, makeWalletCtx } from "./helpers"; + +jest.mock("@/env", () => ({ + __esModule: true, + env: { + DATABASE_URL: process.env.DATABASE_URL, + DIRECT_URL: process.env.DIRECT_URL, + NODE_ENV: "test", + }, +}), { virtual: true }); + +jest.mock("superjson", () => ({ + __esModule: true, + default: { + serialize: (value: unknown) => value, + deserialize: (value: unknown) => value, + }, +})); + +jest.mock("@/server/auth", () => ({ + __esModule: true, + getServerAuthSession: jest.fn(), +})); + +let createCaller: typeof import("@/server/api/root").createCaller; + +const makeMockDb = () => ({ + wallet: { findUnique: jest.fn() }, + transaction: { + create: jest.fn(), + findUnique: jest.fn(), + findMany: jest.fn(), + }, +}); + +const wallet = (overrides: Record = {}) => ({ + id: "wallet-1", + signersAddresses: ["addr_signer"], + ownerAddress: null, + ...overrides, +}); + +describe("transaction router authorization", () => { + beforeAll(async () => { + ({ createCaller } = await import("@/server/api/root")); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("throws UNAUTHORIZED when session is missing", async () => { + const mockDb = makeMockDb(); + const caller = createCaller(makeAnonymousCtx(mockDb) as any); + + await expect( + caller.transaction.createTransaction({ + walletId: "wallet-1", + txJson: "{}", + signedAddresses: [], + txCbor: "deadbeef", + state: 0, + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(mockDb.wallet.findUnique).not.toHaveBeenCalled(); + expect(mockDb.transaction.create).not.toHaveBeenCalled(); + }); + + it("throws NOT_FOUND when wallet does not exist", async () => { + const mockDb = makeMockDb(); + mockDb.wallet.findUnique.mockResolvedValueOnce(null as never); + const caller = createCaller(makeWalletCtx("addr_signer", mockDb) as any); + + await expect( + caller.transaction.createTransaction({ + walletId: "missing-wallet", + txJson: "{}", + signedAddresses: [], + txCbor: "deadbeef", + state: 0, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + expect(mockDb.transaction.create).not.toHaveBeenCalled(); + }); + + it("throws FORBIDDEN when caller is not a signer", async () => { + const mockDb = makeMockDb(); + mockDb.wallet.findUnique.mockResolvedValueOnce(wallet() as never); + const caller = createCaller(makeWalletCtx("addr_outsider", mockDb) as any); + + await expect( + caller.transaction.createTransaction({ + walletId: "wallet-1", + txJson: "{}", + signedAddresses: [], + txCbor: "deadbeef", + state: 0, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(mockDb.transaction.create).not.toHaveBeenCalled(); + }); + + it("allows the wallet owner address", async () => { + const mockDb = makeMockDb(); + mockDb.wallet.findUnique.mockResolvedValueOnce( + wallet({ signersAddresses: ["addr_signer"], ownerAddress: "addr_owner" }) as never, + ); + mockDb.transaction.create.mockResolvedValueOnce({ id: "tx-1", walletId: "wallet-1" } as never); + const caller = createCaller(makeWalletCtx("addr_owner", mockDb) as any); + + await expect( + caller.transaction.createTransaction({ + walletId: "wallet-1", + txJson: "{}", + signedAddresses: [], + txCbor: "deadbeef", + state: 0, + }), + ).resolves.toMatchObject({ id: "tx-1" }); + }); + + it("allows a signer from the wallet-session context", async () => { + const mockDb = makeMockDb(); + mockDb.wallet.findUnique.mockResolvedValueOnce(wallet() as never); + mockDb.transaction.create.mockResolvedValueOnce({ id: "tx-1", walletId: "wallet-1" } as never); + const caller = createCaller(makeWalletCtx("addr_signer", mockDb) as any); + + await expect( + caller.transaction.createTransaction({ + walletId: "wallet-1", + txJson: "{}", + signedAddresses: ["addr_signer"], + txCbor: "deadbeef", + state: 0, + }), + ).resolves.toMatchObject({ id: "tx-1" }); + }); +}); diff --git a/src/__tests__/tx-builders/cborUtils.ts b/src/__tests__/tx-builders/cborUtils.ts deleted file mode 100644 index a045539f..00000000 --- a/src/__tests__/tx-builders/cborUtils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { decode } from "cbor-x"; - -export const TX_BODY_KEYS = { - INPUTS: 0, - OUTPUTS: 1, - FEE: 2, - CERTS: 4, - WITHDRAWALS: 5, - MINT: 9, - VOTES: 19, -} as const; - -export const CERT_KIND = { - STAKE_REGISTRATION: 0, - STAKE_DEREGISTRATION: 1, - STAKE_DELEGATION: 2, - DREP_REGISTRATION: 16, - DREP_DEREGISTRATION: 17, - DREP_UPDATE: 18, -} as const; - -export function decodeTxBody(cbor: string): Map { - const [body] = decode(Buffer.from(cbor, "hex")) as [Map]; - return body; -} - -export function getCerts(body: Map): unknown[][] { - return (body.get(TX_BODY_KEYS.CERTS) as unknown[][] | undefined) ?? []; -} - -export function getWithdrawals(body: Map): Map { - return (body.get(TX_BODY_KEYS.WITHDRAWALS) as Map | undefined) ?? new Map(); -} diff --git a/src/__tests__/tx-builders/infrastructure.test.ts b/src/__tests__/tx-builders/infrastructure.test.ts index 2d97d55e..384e088c 100644 --- a/src/__tests__/tx-builders/infrastructure.test.ts +++ b/src/__tests__/tx-builders/infrastructure.test.ts @@ -1,17 +1,10 @@ import { describe, it, expect } from "@jest/globals"; import { MeshTxBuilder } from "@meshsdk/core"; import { getTestTxBuilder } from "./testTxBuilder"; -import { decode } from "cbor-x"; describe("tx-builder test infrastructure", () => { it("constructs MeshTxBuilder with mock provider", () => { const txBuilder = getTestTxBuilder(); expect(txBuilder).toBeInstanceOf(MeshTxBuilder); }); - - it("cbor-x decodes a round-trip Buffer", () => { - const encoded = Buffer.from("82 01 02".replace(/ /g, ""), "hex"); // [1, 2] - const decoded = decode(encoded); - expect(decoded).toEqual([1, 2]); - }); }); diff --git a/src/server/api/routers/transactions.ts b/src/server/api/routers/transactions.ts index aef26e7b..826229fb 100644 --- a/src/server/api/routers/transactions.ts +++ b/src/server/api/routers/transactions.ts @@ -74,6 +74,7 @@ export const transactionRouter = createTRPCRouter({ walletId: input.walletId, txJson: input.txJson, signedAddresses: input.signedAddresses, + rejectedAddresses: [], txCbor: input.txCbor, state: input.state, description: input.description, From 84035f9c8911a4e5cf13b8a3cbc40879831e5628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Thu, 21 May 2026 06:17:03 +0200 Subject: [PATCH 4/5] refactor: update completeTxWithFreshCostModels integration and enhance tests - Refactored imports to streamline the usage of `completeTxWithFreshCostModels` across the codebase. - Updated unit tests to reflect changes in cost model handling, including support for raw arrays and ordering of indexed cost model objects. - Added new test cases to validate the rejection of improperly ordered cost model objects, ensuring robustness in transaction processing. --- .../completeTxWithFreshCostModels.test.ts | 62 +++- src/hooks/useTransaction.ts | 3 +- src/lib/completeTxWithFreshCostModels.ts | 265 ++++++++++++++++++ .../server/completeTxWithFreshCostModels.ts | 184 +----------- 4 files changed, 326 insertions(+), 188 deletions(-) create mode 100644 src/lib/completeTxWithFreshCostModels.ts diff --git a/src/__tests__/completeTxWithFreshCostModels.test.ts b/src/__tests__/completeTxWithFreshCostModels.test.ts index 4467dd01..16b6f7cf 100644 --- a/src/__tests__/completeTxWithFreshCostModels.test.ts +++ b/src/__tests__/completeTxWithFreshCostModels.test.ts @@ -122,24 +122,21 @@ describe("refreshScriptDataHash", () => { }); it("leaves transactions without redeemers unchanged", async () => { - const { refreshScriptDataHash } = await import("@/lib/server/completeTxWithFreshCostModels"); + const { refreshScriptDataHash } = await import("@/lib/completeTxWithFreshCostModels"); expect(refreshScriptDataHash("unsigned-tx-hex", {}, {})).toBe("unsigned-tx-hex"); expect(hashScriptDataMock).not.toHaveBeenCalled(); expect(setScriptDataHashMock).not.toHaveBeenCalled(); }); - it("recomputes script data hash with current Plutus V3 cost model", async () => { + it("recomputes script data hash with Plutus V3 cost_models_raw arrays", async () => { MockTransaction.configure({ redeemerCount: 1, updatedHex: "fresh-tx-hex" }); - const { refreshScriptDataHash } = await import("@/lib/server/completeTxWithFreshCostModels"); + const { refreshScriptDataHash } = await import("@/lib/completeTxWithFreshCostModels"); const refreshed = refreshScriptDataHash( "unsigned-tx-hex", { - PlutusV3: { - "builtin-a": 10, - "builtin-b": 20, - }, + PlutusV3: [10, 20], }, { mints: [ @@ -157,4 +154,55 @@ describe("refreshScriptDataHash", () => { expect(hashScriptDataMock).toHaveBeenCalled(); expect(setScriptDataHashMock).toHaveBeenCalledWith({ hash: "fresh-script-data-hash" }); }); + + it("orders indexed cost model objects by numeric key", async () => { + MockTransaction.configure({ redeemerCount: 1, updatedHex: "fresh-tx-hex" }); + const { refreshScriptDataHash } = await import("@/lib/completeTxWithFreshCostModels"); + + refreshScriptDataHash( + "unsigned-tx-hex", + { + PlutusV3: { + "2": 30, + "0": 10, + "1": 20, + }, + }, + { + mints: [ + { + type: "Plutus", + scriptSource: { script: { version: "V3" } }, + }, + ], + }, + ); + + expect(costModelValues).toEqual([[10, 20, 30]]); + }); + + it("rejects named cost_models objects that are not in ledger order", async () => { + MockTransaction.configure({ redeemerCount: 1 }); + const { refreshScriptDataHash } = await import("@/lib/completeTxWithFreshCostModels"); + + expect(() => + refreshScriptDataHash( + "unsigned-tx-hex", + { + PlutusV3: { + "addInteger-cpu-arguments-intercept": 10, + "addInteger-cpu-arguments-slope": 20, + }, + }, + { + mints: [ + { + type: "Plutus", + scriptSource: { script: { version: "V3" } }, + }, + ], + }, + ), + ).toThrow(/cost_models_raw/); + }); }); diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts index 2fcc5b2d..b0d98665 100644 --- a/src/hooks/useTransaction.ts +++ b/src/hooks/useTransaction.ts @@ -11,6 +11,7 @@ import { shouldSubmitMultisigTx, submitTxWithScriptRecovery, } from "@/utils/txSignUtils"; +import { completeTxWithFreshCostModels } from "@/lib/completeTxWithFreshCostModels"; import { getProvider } from "@/utils/get-provider"; export default function useTransaction() { @@ -103,7 +104,7 @@ export default function useTransaction() { }); } - const unsignedTx = await data.txBuilder.complete(); + const unsignedTx = await completeTxWithFreshCostModels(data.txBuilder, network); if (!activeWallet) { throw new Error("No wallet available for signing transaction"); diff --git a/src/lib/completeTxWithFreshCostModels.ts b/src/lib/completeTxWithFreshCostModels.ts new file mode 100644 index 00000000..fc6bba1e --- /dev/null +++ b/src/lib/completeTxWithFreshCostModels.ts @@ -0,0 +1,265 @@ +import type { MeshTxBuilder } from "@meshsdk/core"; +import { csl } from "@meshsdk/core-csl"; +import { env } from "@/env"; + +type MeshTxBuilderWithBody = MeshTxBuilder & { + meshTxBuilderBody?: unknown; +}; + +type PlutusLanguage = "V1" | "V2" | "V3"; + +type BlockfrostProtocolParameters = { + cost_models?: unknown; + /** Arrays in ledger enumeration order (preferred for script integrity hash). */ + cost_models_raw?: unknown; +}; + +const BLOCKFROST_BASE_URL_BY_NETWORK: Record = { + 0: "https://cardano-preprod.blockfrost.io/api/v0", + 1: "https://cardano-mainnet.blockfrost.io/api/v0", +}; + +function getBlockfrostProjectId(network: number): string { + const projectId = + network === 0 + ? process.env.CI_BLOCKFROST_PREPROD_API_KEY?.trim() || + env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD?.trim() + : process.env.CI_BLOCKFROST_MAINNET_API_KEY?.trim() || + env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET?.trim(); + if (!projectId) { + throw new Error(`Missing Blockfrost project id for network ${network}`); + } + return projectId; +} + +function getBlockfrostBaseUrl(network: number): string { + const baseUrl = BLOCKFROST_BASE_URL_BY_NETWORK[network]; + if (!baseUrl) { + throw new Error(`Unsupported Cardano network id ${network}`); + } + return baseUrl; +} + +async function fetchLatestCostModels(network: number): Promise { + const response = await fetch(`${getBlockfrostBaseUrl(network)}/epochs/latest/parameters`, { + headers: { + project_id: getBlockfrostProjectId(network), + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `Failed to fetch latest Blockfrost protocol parameters (${response.status}): ${body}`, + ); + } + + const parameters = (await response.json()) as BlockfrostProtocolParameters; + if (parameters.cost_models_raw && typeof parameters.cost_models_raw === "object") { + return parameters.cost_models_raw; + } + if (parameters.cost_models && typeof parameters.cost_models === "object") { + return parameters.cost_models; + } + throw new Error( + "Latest Blockfrost protocol parameters did not include cost_models_raw or cost_models", + ); +} + +function isNumericKeyRecord(record: Record): boolean { + const keys = Object.keys(record); + return keys.length > 0 && keys.every((key) => /^\d+$/.test(key)); +} + +function normalizeIndexedCostModel(record: Record): number[] { + return Object.keys(record) + .sort((left, right) => Number(left) - Number(right)) + .map((key) => { + const cost = Number(record[key]); + if (!Number.isInteger(cost)) { + throw new Error(`Invalid cost model value at index ${key}`); + } + return cost; + }); +} + +function normalizeCostModelValues(value: unknown): number[] { + if (Array.isArray(value)) { + return value.map((entry, index) => { + const cost = Number(entry); + if (!Number.isInteger(cost)) { + throw new Error(`Invalid cost model value at index ${index}`); + } + return cost; + }); + } + if (value && typeof value === "object") { + const record = value as Record; + if (isNumericKeyRecord(record)) { + return normalizeIndexedCostModel(record); + } + throw new Error( + "Named Blockfrost cost_models are not in ledger order; use cost_models_raw from /epochs/latest/parameters", + ); + } + throw new Error("Invalid Blockfrost cost model shape"); +} + +function toCostModel(value: unknown): csl.CostModel { + const costModel = csl.CostModel.new(); + normalizeCostModelValues(value).forEach((cost, index) => { + if (!Number.isInteger(cost)) { + throw new Error(`Invalid cost model value at index ${index}`); + } + costModel.set(index, csl.Int.new_i32(cost)); + }); + return costModel; +} + +function findCostModel( + costModels: Record, + language: "PlutusV1" | "PlutusV2" | "PlutusV3", +): unknown { + const aliases: Record = { + PlutusV1: ["PlutusV1", "plutus:v1", "V1", "0"], + PlutusV2: ["PlutusV2", "plutus:v2", "V2", "1"], + PlutusV3: ["PlutusV3", "plutus:v3", "V3", "2"], + }; + + for (const alias of aliases[language]) { + if (costModels[alias]) return costModels[alias]; + } + + throw new Error(`Latest Blockfrost protocol parameters did not include ${language} cost model`); +} + +function languageKindToVersion(kind: number): PlutusLanguage | undefined { + if (kind === 0) return "V1"; + if (kind === 1) return "V2"; + if (kind === 2) return "V3"; + return undefined; +} + +function collectPlutusLanguagesFromBuilder(builderBody: unknown): Set { + const languages = new Set(); + + const visit = (value: unknown) => { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + + const record = value as Record; + const version = record.version; + if (version === "V1" || version === "V2" || version === "V3") { + languages.add(version); + } + + for (const child of Object.values(record)) { + visit(child); + } + }; + + visit(builderBody); + return languages; +} + +function collectPlutusLanguagesFromWitness( + witnessSet: csl.TransactionWitnessSet, +): Set { + const languages = new Set(); + const scripts = + typeof witnessSet.plutus_scripts === "function" + ? witnessSet.plutus_scripts() + : undefined; + if (!scripts) { + return languages; + } + + for (let index = 0; index < scripts.len(); index++) { + const version = languageKindToVersion(scripts.get(index).language_version().kind()); + if (version) { + languages.add(version); + } + } + + return languages; +} + +function collectPlutusLanguages( + witnessSet: csl.TransactionWitnessSet, + builderBody: unknown, +): Set { + const languages = new Set([ + ...collectPlutusLanguagesFromBuilder(builderBody), + ...collectPlutusLanguagesFromWitness(witnessSet), + ]); + + const redeemers = witnessSet.redeemers(); + if (languages.size === 0 && redeemers && redeemers.len() > 0) { + languages.add("V3"); + } + + return languages; +} + +function toCostmdls( + costModels: unknown, + languages: Set, +): csl.Costmdls { + const rawCostModels = costModels as Record; + const costmdls = csl.Costmdls.new(); + + if (languages.has("V1")) { + costmdls.insert(csl.Language.new_plutus_v1(), toCostModel(findCostModel(rawCostModels, "PlutusV1"))); + } + if (languages.has("V2")) { + costmdls.insert(csl.Language.new_plutus_v2(), toCostModel(findCostModel(rawCostModels, "PlutusV2"))); + } + if (languages.has("V3")) { + costmdls.insert(csl.Language.new_plutus_v3(), toCostModel(findCostModel(rawCostModels, "PlutusV3"))); + } + + return costmdls; +} + +export function refreshScriptDataHash( + txHex: string, + costModels: unknown, + builderBody: unknown, +): string { + const tx = csl.Transaction.from_hex(txHex); + const witnessSet = tx.witness_set(); + const redeemers = witnessSet.redeemers(); + if (!redeemers || redeemers.len() === 0) { + return txHex; + } + + const languages = collectPlutusLanguages(witnessSet, builderBody); + if (languages.size === 0) { + return txHex; + } + + const scriptDataHash = csl.hash_script_data( + redeemers, + toCostmdls(costModels, languages), + witnessSet.plutus_data(), + ); + + const body = tx.body(); + body.set_script_data_hash(scriptDataHash); + + const updatedTx = csl.Transaction.new(body, witnessSet, tx.auxiliary_data()); + updatedTx.set_is_valid(tx.is_valid()); + return updatedTx.to_hex(); +} + +export async function completeTxWithFreshCostModels( + txBuilder: MeshTxBuilderWithBody, + network: number, +): Promise { + const txHex = await txBuilder.complete(); + const costModels = await fetchLatestCostModels(network); + return refreshScriptDataHash(txHex, costModels, txBuilder.meshTxBuilderBody); +} diff --git a/src/lib/server/completeTxWithFreshCostModels.ts b/src/lib/server/completeTxWithFreshCostModels.ts index 8e9eb3ea..bfb3c35b 100644 --- a/src/lib/server/completeTxWithFreshCostModels.ts +++ b/src/lib/server/completeTxWithFreshCostModels.ts @@ -1,180 +1,4 @@ -import type { MeshTxBuilder } from "@meshsdk/core"; -import { csl } from "@meshsdk/core-csl"; - -type MeshTxBuilderWithBody = MeshTxBuilder & { - meshTxBuilderBody?: unknown; -}; - -type BlockfrostProtocolParameters = { - cost_models?: unknown; -}; - -const BLOCKFROST_BASE_URL_BY_NETWORK: Record = { - 0: "https://cardano-preprod.blockfrost.io/api/v0", - 1: "https://cardano-mainnet.blockfrost.io/api/v0", -}; - -function getBlockfrostProjectId(network: number): string { - const projectId = - network === 0 - ? process.env.CI_BLOCKFROST_PREPROD_API_KEY?.trim() || - process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD?.trim() - : process.env.CI_BLOCKFROST_MAINNET_API_KEY?.trim() || - process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET?.trim(); - if (!projectId) { - throw new Error(`Missing Blockfrost project id for network ${network}`); - } - return projectId; -} - -function getBlockfrostBaseUrl(network: number): string { - const baseUrl = BLOCKFROST_BASE_URL_BY_NETWORK[network]; - if (!baseUrl) { - throw new Error(`Unsupported Cardano network id ${network}`); - } - return baseUrl; -} - -async function fetchLatestCostModels(network: number): Promise { - const response = await fetch(`${getBlockfrostBaseUrl(network)}/epochs/latest/parameters`, { - headers: { - project_id: getBlockfrostProjectId(network), - }, - }); - - if (!response.ok) { - const body = await response.text().catch(() => ""); - throw new Error( - `Failed to fetch latest Blockfrost protocol parameters (${response.status}): ${body}`, - ); - } - - const parameters = (await response.json()) as BlockfrostProtocolParameters; - if (!parameters.cost_models || typeof parameters.cost_models !== "object") { - throw new Error("Latest Blockfrost protocol parameters did not include cost_models"); - } - return parameters.cost_models; -} - -function normalizeCostModelValues(value: unknown): number[] { - if (Array.isArray(value)) { - return value.map((entry) => Number(entry)); - } - if (value && typeof value === "object") { - return Object.values(value as Record).map((entry) => Number(entry)); - } - throw new Error("Invalid Blockfrost cost model shape"); -} - -function toCostModel(value: unknown): csl.CostModel { - const costModel = csl.CostModel.new(); - normalizeCostModelValues(value).forEach((cost, index) => { - if (!Number.isInteger(cost)) { - throw new Error(`Invalid cost model value at index ${index}`); - } - costModel.set(index, csl.Int.new_i32(cost)); - }); - return costModel; -} - -function findCostModel( - costModels: Record, - language: "PlutusV1" | "PlutusV2" | "PlutusV3", -): unknown { - const aliases: Record = { - PlutusV1: ["PlutusV1", "plutus:v1", "V1", "0"], - PlutusV2: ["PlutusV2", "plutus:v2", "V2", "1"], - PlutusV3: ["PlutusV3", "plutus:v3", "V3", "2"], - }; - - for (const alias of aliases[language]) { - if (costModels[alias]) return costModels[alias]; - } - - throw new Error(`Latest Blockfrost protocol parameters did not include ${language} cost model`); -} - -function collectPlutusLanguages(builderBody: unknown): Set<"V1" | "V2" | "V3"> { - const languages = new Set<"V1" | "V2" | "V3">(); - - const visit = (value: unknown) => { - if (!value || typeof value !== "object") return; - if (Array.isArray(value)) { - value.forEach(visit); - return; - } - - const record = value as Record; - const version = record.version; - if (version === "V1" || version === "V2" || version === "V3") { - languages.add(version); - } - - for (const child of Object.values(record)) { - visit(child); - } - }; - - visit(builderBody); - return languages; -} - -function toCostmdls( - costModels: unknown, - languages: Set<"V1" | "V2" | "V3">, -): csl.Costmdls { - const rawCostModels = costModels as Record; - const costmdls = csl.Costmdls.new(); - - if (languages.has("V1")) { - costmdls.insert(csl.Language.new_plutus_v1(), toCostModel(findCostModel(rawCostModels, "PlutusV1"))); - } - if (languages.has("V2")) { - costmdls.insert(csl.Language.new_plutus_v2(), toCostModel(findCostModel(rawCostModels, "PlutusV2"))); - } - if (languages.has("V3")) { - costmdls.insert(csl.Language.new_plutus_v3(), toCostModel(findCostModel(rawCostModels, "PlutusV3"))); - } - - return costmdls; -} - -export function refreshScriptDataHash( - txHex: string, - costModels: unknown, - builderBody: unknown, -): string { - const tx = csl.Transaction.from_hex(txHex); - const witnessSet = tx.witness_set(); - const redeemers = witnessSet.redeemers(); - if (!redeemers || redeemers.len() === 0) { - return txHex; - } - - const languages = collectPlutusLanguages(builderBody); - if (languages.size === 0) { - return txHex; - } - - const scriptDataHash = csl.hash_script_data( - redeemers, - toCostmdls(costModels, languages), - witnessSet.plutus_data(), - ); - - const body = tx.body(); - body.set_script_data_hash(scriptDataHash); - - const updatedTx = csl.Transaction.new(body, witnessSet, tx.auxiliary_data()); - updatedTx.set_is_valid(tx.is_valid()); - return updatedTx.to_hex(); -} - -export async function completeTxWithFreshCostModels( - txBuilder: MeshTxBuilderWithBody, - network: number, -): Promise { - const txHex = await txBuilder.complete(); - const costModels = await fetchLatestCostModels(network); - return refreshScriptDataHash(txHex, costModels, txBuilder.meshTxBuilderBody); -} +export { + completeTxWithFreshCostModels, + refreshScriptDataHash, +} from "@/lib/completeTxWithFreshCostModels"; From 478b16087963270fa5cd55df2c7ddb4f4459e586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Thu, 21 May 2026 14:32:15 +0200 Subject: [PATCH 5/5] feat: enhance proxy transaction handling with blocked UTxO management - Integrated functions to extract and filter blocked UTxOs from pending transactions, improving the robustness of proxy transaction processing. - Updated `MeshProxyContract` to utilize blocked UTxO references when selecting UTxOs for spending, ensuring only available UTxOs are used. - Enhanced `ProxyControl` component to manage blocked UTxOs and ensure proper handling of available UTxOs for transactions. - Refactored UTxO selection logic to improve clarity and maintainability in the proxy transaction workflow. --- .../multisig/proxy/ProxyControl.tsx | 41 +++- src/components/multisig/proxy/offchain.ts | 194 ++++-------------- src/lib/server/proxyUtxos.ts | 72 ++++++- 3 files changed, 149 insertions(+), 158 deletions(-) diff --git a/src/components/multisig/proxy/ProxyControl.tsx b/src/components/multisig/proxy/ProxyControl.tsx index e575086f..806b61df 100644 --- a/src/components/multisig/proxy/ProxyControl.tsx +++ b/src/components/multisig/proxy/ProxyControl.tsx @@ -11,6 +11,10 @@ import ProxySetup from "./ProxySetup"; import ProxySpend from "./ProxySpend"; import UTxOSelector from "@/components/pages/wallet/new-transaction/utxoSelector"; import { getProvider } from "@/utils/get-provider"; +import { + extractBlockedUtxoRefsFromPendingTxJson, + filterBlockedUtxos, +} from "@/lib/server/proxyUtxos"; import type { MeshTxBuilder, UTxO } from "@meshsdk/core"; import { useProxy } from "@/hooks/useProxy"; import { useProxyData } from "@/lib/zustand/proxy"; @@ -119,6 +123,23 @@ export default function ProxyControl() { }, }); + const { data: pendingTransactions } = api.transaction.getPendingTransactions.useQuery( + { walletId: appWallet?.id ?? "" }, + { + enabled: !!appWallet?.id, + staleTime: 30 * 1000, + refetchOnWindowFocus: false, + }, + ); + + const blockedUtxoRefs = useMemo( + () => + (pendingTransactions ?? []).flatMap((transaction) => + extractBlockedUtxoRefsFromPendingTxJson(transaction.txJson), + ), + [pendingTransactions], + ); + // State management const [proxyContract, setProxyContract] = useState(null); const [isProxySetup, setIsProxySetup] = useState(false); @@ -163,9 +184,14 @@ export default function ProxyControl() { if (!utxos || utxos.length === 0) { throw new Error("No UTxOs found at multisig wallet address"); } + + const freeUtxos = filterBlockedUtxos(utxos, blockedUtxoRefs); + if (freeUtxos.length === 0) { + throw new Error("No free UTxOs found at multisig wallet address"); + } - return { utxos, walletAddress: appWallet.address }; - }, [appWallet?.address, network]); + return { utxos: freeUtxos, walletAddress: appWallet.address }; + }, [appWallet?.address, blockedUtxoRefs, network]); // Initialize proxy contract const contractInitializedRef = useRef(false); @@ -176,7 +202,7 @@ export default function ProxyControl() { // Only initialize once if (!contractInitializedRef.current) { try { - const txBuilder = getTxBuilder(network); + const txBuilder = getTxBuilder(network, true); const contract = new MeshProxyContract( { mesh: txBuilder, @@ -615,7 +641,7 @@ export default function ProxyControl() { const selectedProxyContract = new MeshProxyContract( { - mesh: getTxBuilder(network), + mesh: getTxBuilder(network, true), wallet: activeWallet, networkId: network, }, @@ -628,7 +654,12 @@ export default function ProxyControl() { // Pass multisig inputs to spend as well const { utxos, walletAddress } = await getMsInputs(); - const txHex = await selectedProxyContract.spendProxySimple(validOutputs, utxos, walletAddress); + const txHex = await selectedProxyContract.spendProxySimple( + validOutputs, + utxos, + walletAddress, + blockedUtxoRefs, + ); if (appWallet?.scriptCbor) { await newTransaction({ txBuilder: txHex, diff --git a/src/components/multisig/proxy/offchain.ts b/src/components/multisig/proxy/offchain.ts index 6d02a47a..c29aca78 100644 --- a/src/components/multisig/proxy/offchain.ts +++ b/src/components/multisig/proxy/offchain.ts @@ -8,6 +8,13 @@ import { import type { UTxO, MeshTxBuilder } from "@meshsdk/core"; // import { parseDatumCbor } from "@meshsdk/core-cst"; import { parseProposalId } from "@/lib/governance"; +import { buildProxySpendTx } from "@/lib/server/proxyTxBuilders"; +import { + selectFreeAuthTokenUtxo, + selectProxyUtxosForOutputs, + type UtxoRef, +} from "@/lib/server/proxyUtxos"; +import { getTxBuilder } from "@/utils/get-tx-builder"; import { MeshTxInitiator } from "./common"; import type { MeshTxInitiatorInput } from "./common"; @@ -193,6 +200,7 @@ export class MeshProxyContract extends MeshTxInitiator { outputs: { address: string; unit: string; amount: string }[], msUtxos?: UTxO[], msWalletAddress?: string, + blockedUtxoRefs: UtxoRef[] = [], ) => { if (this.msCbor && !msUtxos && !msWalletAddress) { throw new Error( @@ -202,7 +210,6 @@ export class MeshProxyContract extends MeshTxInitiator { const walletInfo = await this.getWalletInfoForTx(); let { utxos, walletAddress } = walletInfo; const { collateral } = walletInfo; - // If multisig inputs are provided, use them instead of the wallet inputs if (this.msCbor && msUtxos && msWalletAddress) { utxos = msUtxos; walletAddress = msWalletAddress; @@ -219,164 +226,51 @@ export class MeshProxyContract extends MeshTxInitiator { if (this.proxyAddress === undefined) { throw new Error("Proxy address not set. Please setupProxy first."); } + if (!this.paramUtxo.txHash) { + throw new Error("Proxy param UTxO is not set. Please setupProxy first."); + } const blockchainProvider = this.mesh.fetcher; if (!blockchainProvider) { throw new Error("Blockchain provider not found"); } - const proxyUtxos = await blockchainProvider.fetchAddressUTxOs( - this.proxyAddress, + const policyIdAT = resolveScriptHash(this.getAuthTokenCbor(), "V3"); + const authTokenUtxo = selectFreeAuthTokenUtxo( + utxos, + policyIdAT, + blockedUtxoRefs, ); - - // Calculate spend requirements and ensure coverage by proxy UTxOs - const REQUIRED_FEE_BUFFER = BigInt(500_000); // 0.5 ADA buffer in lovelace - - const requiredByUnit = new Map(); - for (const out of outputs) { - const prev = requiredByUnit.get(out.unit) ?? BigInt(0); - requiredByUnit.set(out.unit, prev + BigInt(out.amount)); - } - // Add buffer to lovelace - const lovelaceNeed = - (requiredByUnit.get("lovelace") ?? BigInt(0)) + REQUIRED_FEE_BUFFER; - requiredByUnit.set("lovelace", lovelaceNeed); - - const availableByUnit = new Map(); - for (const utxo of proxyUtxos) { - for (const asset of utxo.output.amount) { - const prev = availableByUnit.get(asset.unit) ?? BigInt(0); - availableByUnit.set(asset.unit, prev + BigInt(asset.quantity)); - } + if ("error" in authTokenUtxo) { + throw new Error(authTokenUtxo.error); } - for (const [unit, needed] of requiredByUnit.entries()) { - const available = availableByUnit.get(unit) ?? BigInt(0); - if (available < needed) { - throw new Error( - `Insufficient proxy balance for ${unit}. Needed: ${needed.toString()}, Available: ${available.toString()}`, - ); - } - } - - // Select as few UTxOs as possible to cover required amounts - const remainingByUnit = new Map(requiredByUnit); - const candidateUtxos = [...proxyUtxos]; - const selectedUtxos: typeof proxyUtxos = []; - - const hasRemaining = () => { - for (const value of remainingByUnit.values()) { - if (value > BigInt(0)) return true; - } - return false; - }; - - const contributionScore = (utxo: (typeof proxyUtxos)[number]) => { - let score = BigInt(0); - for (const asset of utxo.output.amount) { - const remaining = remainingByUnit.get(asset.unit) ?? BigInt(0); - if (remaining > BigInt(0)) { - const qty = BigInt(asset.quantity); - score += qty < remaining ? qty : remaining; - } - } - return score; - }; - - while (hasRemaining()) { - let bestIdx = -1; - let bestScore = BigInt(0); - for (let i = 0; i < candidateUtxos.length; i++) { - const s = contributionScore(candidateUtxos[i]!); - if (s > bestScore) { - bestScore = s; - bestIdx = i; - } - } - if (bestIdx === -1 || bestScore === BigInt(0)) { - throw new Error( - "Unable to select proxy UTxOs to cover required amounts.", - ); - } - const chosen = candidateUtxos.splice(bestIdx, 1)[0]!; - selectedUtxos.push(chosen); - // Decrease remaining by chosen utxo's amounts - for (const asset of chosen.output.amount) { - const remaining = remainingByUnit.get(asset.unit) ?? BigInt(0); - if (remaining > BigInt(0)) { - const qty = BigInt(asset.quantity); - const newRemaining = remaining - (qty < remaining ? qty : remaining); - remainingByUnit.set(asset.unit, newRemaining); - } - } - } - - const freeProxyUtxos = selectedUtxos; - const paramScriptAT = this.getAuthTokenCbor(); - const policyIdAT = resolveScriptHash(paramScriptAT, "V3"); - const authTokenUtxos = utxos.filter((utxo) => - utxo.output.amount.some((asset) => asset.unit === policyIdAT), + const proxyUtxos = await blockchainProvider.fetchAddressUTxOs( + this.proxyAddress, ); - - if (!authTokenUtxos || authTokenUtxos.length === 0) { - throw new Error("No AuthToken found at control wallet address"); - } - //ToDo check if AuthToken utxo is used in a pending transaction and blocked then use a free AuthToken - const authTokenUtxo = authTokenUtxos[0]; - if (!authTokenUtxo) { - throw new Error("No AuthToken found"); - } - const authTokenUtxoAmt = authTokenUtxo.output.amount; - if (!authTokenUtxoAmt) { - throw new Error("No AuthToken amount found"); - } - - //prepare Proxy spend - //1 Get - const txHex = this.mesh; - - for (const input of freeProxyUtxos) { - txHex - .spendingPlutusScriptV3() - .txIn( - input.input.txHash, - input.input.outputIndex, - input.output.amount, - input.output.address, - ) - .txInScript(this.getProxyCbor()) - .txInInlineDatumPresent() - .txInRedeemerValue(mConStr0([])); - } - - txHex - .txIn( - authTokenUtxo.input.txHash, - authTokenUtxo.input.outputIndex, - authTokenUtxo.output.amount, - authTokenUtxo.output.address, - ) - .txInCollateral( - collateral.input.txHash, - collateral.input.outputIndex, - collateral.output.amount, - collateral.output.address, - ) - .txOut(walletAddress, [{ unit: policyIdAT, quantity: "1" }]); - - for (const output of outputs) { - txHex.txOut(output.address, [ - { unit: output.unit, quantity: output.amount }, - ]); - } - - txHex.changeAddress(this.proxyAddress); - - // Add the multisig script cbor if it exists (like in setupProxy) - if (this.msCbor) { - txHex.txInScript(this.msCbor); - } - - return txHex; + const selectedProxyUtxos = selectProxyUtxosForOutputs({ + proxyUtxos, + outputs, + }); + if ("error" in selectedProxyUtxos) { + throw new Error(selectedProxyUtxos.error); + } + + const txBuilder = getTxBuilder(this.networkId, true); + buildProxySpendTx({ + txBuilder, + network: this.networkId, + proxyAddress: this.proxyAddress, + paramUtxo: this.paramUtxo, + walletUtxos: [authTokenUtxo], + proxyUtxos: selectedProxyUtxos, + authTokenUtxo, + collateral, + outputs, + walletAddress, + multisigScriptCbor: this.msCbor, + }); + + return txBuilder; }; manageProxyDrep = async ( diff --git a/src/lib/server/proxyUtxos.ts b/src/lib/server/proxyUtxos.ts index 3cf5bb43..cadb6364 100644 --- a/src/lib/server/proxyUtxos.ts +++ b/src/lib/server/proxyUtxos.ts @@ -118,14 +118,80 @@ export async function resolveCollateralRefFromChain(args: { return { collateral: resolved.utxo }; } +export function filterBlockedUtxos( + utxos: UTxO[], + blockedRefs: UtxoRef[], +): UTxO[] { + if (blockedRefs.length === 0) { + return utxos; + } + + const blocked = new Set( + blockedRefs.map((ref) => `${ref.txHash}#${ref.outputIndex}`), + ); + + return utxos.filter( + (utxo) => !blocked.has(`${utxo.input.txHash}#${utxo.input.outputIndex}`), + ); +} + +export function extractBlockedUtxoRefsFromPendingTxJson(txJson: string): UtxoRef[] { + try { + const parsed = JSON.parse(txJson) as { + inputs?: Array<{ txIn?: { txHash?: string; txIndex?: number } }>; + }; + if (!Array.isArray(parsed.inputs)) { + return []; + } + + return parsed.inputs + .map((input) => ({ + txHash: typeof input.txIn?.txHash === "string" ? input.txIn.txHash : "", + outputIndex: + typeof input.txIn?.txIndex === "number" && Number.isInteger(input.txIn.txIndex) + ? input.txIn.txIndex + : -1, + })) + .filter((ref) => ref.txHash.length > 0 && ref.outputIndex >= 0); + } catch { + return []; + } +} + +export function selectFreeAuthTokenUtxo( + utxos: UTxO[], + authTokenId: string, + blockedRefs: UtxoRef[] = [], +): UTxO | { error: string } { + const freeUtxos = filterBlockedUtxos(utxos, blockedRefs); + const authTokenUtxos = freeUtxos.filter((utxo) => hasAsset(utxo, authTokenId)); + if (authTokenUtxos.length === 0) { + return { + error: + "No free proxy auth-token UTxO found at the multisig wallet address. Cancel or complete pending transactions that use the auth token, then try again.", + }; + } + + return authTokenUtxos.sort((left, right) => { + const lovelaceDelta = Number(getLovelace(right) - getLovelace(left)); + if (lovelaceDelta !== 0) { + return lovelaceDelta; + } + if (left.input.txHash !== right.input.txHash) { + return left.input.txHash.localeCompare(right.input.txHash); + } + return left.input.outputIndex - right.input.outputIndex; + })[0]!; +} + export function requireAuthTokenUtxo( utxos: UTxO[], authTokenId: string, ): UTxO | { error: string; status: number } { - const authTokenUtxo = utxos.find((utxo) => hasAsset(utxo, authTokenId)); - if (!authTokenUtxo) { + const authTokenUtxo = selectFreeAuthTokenUtxo(utxos, authTokenId); + if ("error" in authTokenUtxo) { return { - error: "No proxy auth-token UTxO found at the multisig wallet address", + error: authTokenUtxo.error, status: 400, }; }