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/6] 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/6] 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/6] 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/6] 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/6] 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, }; } From 52d5a5d0d3cc1fabb943bf9cc7d9aea7634fe7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Diamond?= <32074058+Andre-Diamond@users.noreply.github.com> Date: Fri, 22 May 2026 16:14:12 +0200 Subject: [PATCH 6/6] Refactor proxy UTxO selection and transaction building - Updated `proxySpend.ts` and `proxyVote.ts` to use `selectAuthTokenUtxo` for selecting the auth token UTxO, incorporating blocked UTxO references. - Introduced new utility functions in `utxoUtils.ts` for selecting UTxOs and checking asset presence. - Added comprehensive tests for UTxO selection logic in `proxy-utxo-selection.test.ts`. - Created `txBuilders.ts` to encapsulate transaction building logic for proxy actions, including setup, spending, voting, and DRep certificate management. - Implemented cross-verification tests in `proxy-cross-verify.test.ts` to ensure consistency between direct and browser-based transaction builders. --- .../createPendingMultisigTransaction.test.ts | 4 +- src/__tests__/proxy-cross-verify.test.ts | 262 ++++++++ src/__tests__/proxy-utxo-selection.test.ts | 152 +++++ src/__tests__/proxyCiPreflight.test.ts | 9 +- src/__tests__/proxyCleanup.bot.test.ts | 10 + src/__tests__/proxyDRepInfo.test.ts | 2 +- src/__tests__/proxyTxBuilders.test.ts | 2 +- src/__tests__/tx-builders/proxy.test.ts | 97 ++- .../multisig/proxy/ProxyControl.tsx | 41 +- src/components/multisig/proxy/offchain.ts | 562 +++++------------- .../wallet/governance/drep/registerDrep.tsx | 21 +- .../wallet/governance/drep/updateDrep.tsx | 21 +- src/lib/proxy/txBuilders.ts | 496 ++++++++++++++++ src/lib/proxy/utxoUtils.ts | 123 ++++ src/lib/server/proxyTxBuilders.ts | 495 +-------------- src/lib/server/proxyUtxos.ts | 38 +- src/pages/api/v1/proxyCleanup.ts | 15 +- src/pages/api/v1/proxyDRepCertificate.ts | 16 +- src/pages/api/v1/proxySpend.ts | 15 +- src/pages/api/v1/proxyVote.ts | 16 +- 20 files changed, 1355 insertions(+), 1042 deletions(-) create mode 100644 src/__tests__/proxy-cross-verify.test.ts create mode 100644 src/__tests__/proxy-utxo-selection.test.ts create mode 100644 src/lib/proxy/txBuilders.ts create mode 100644 src/lib/proxy/utxoUtils.ts diff --git a/src/__tests__/createPendingMultisigTransaction.test.ts b/src/__tests__/createPendingMultisigTransaction.test.ts index 9c53ce87..3d51ec08 100644 --- a/src/__tests__/createPendingMultisigTransaction.test.ts +++ b/src/__tests__/createPendingMultisigTransaction.test.ts @@ -1,7 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; import type { PrismaClient } from "@prisma/client"; -const submitTxMock = jest.fn(); +const submitTxMock = jest.fn<(txCbor: string) => Promise>(); jest.mock("@/utils/get-provider", () => ({ __esModule: true, @@ -13,7 +13,7 @@ let createPendingMultisigTransaction: typeof import("@/lib/server/createPendingM function makeDb() { return { transaction: { - create: jest.fn().mockResolvedValue({ id: "tx-1" }), + create: jest.fn<() => Promise<{ id: string }>>().mockResolvedValue({ id: "tx-1" }), }, } as unknown as PrismaClient; } diff --git a/src/__tests__/proxy-cross-verify.test.ts b/src/__tests__/proxy-cross-verify.test.ts new file mode 100644 index 00000000..6bda8a28 --- /dev/null +++ b/src/__tests__/proxy-cross-verify.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, jest, afterEach } from "@jest/globals"; +import type { UTxO } from "@meshsdk/core"; +import { MeshProxyContract } from "@/components/multisig/proxy/offchain"; +import { + buildProxySetupTx, + buildProxyDRepCertificateTx, + buildProxyVoteTx, + buildProxySpendTx, + deriveProxyScripts, +} from "@/lib/proxy/txBuilders"; +import { + selectProxyUtxosForOutputs, + selectAuthTokenUtxo, +} from "@/lib/proxy/utxoUtils"; +import { + FIXTURE_COLLATERAL, + PARAM_UTXO, + CHANGE_ADDRESS, +} from "./tx-builders/fixtures"; +import { createMockProvider } from "./tx-builders/mockProvider"; + +// ─── Mesh Builder Mock ──────────────────────────────────────────────────────── + +interface BuilderCall { method: string; args: unknown[] } + +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; + if (method === "then") return undefined; + return (...args: unknown[]) => { + calls.push({ method, args }); + return mesh; + }; + }, + set() { return true; }, + }); + return { mesh, calls }; +} + +// ─── Shared Fixtures ────────────────────────────────────────────────────────── + +const LARGE_UTXO: UTxO = { + input: { txHash: PARAM_UTXO.txHash, outputIndex: PARAM_UTXO.outputIndex }, + output: { + address: CHANGE_ADDRESS, + amount: [{ unit: "lovelace", quantity: "25000000" }], + }, +}; + +const ANCHOR_URL = "https://example.com/drep.json"; +const ANCHOR_JSON = { + "@context": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0119/README.md", + hashAlgorithm: "blake2b-256", + body: { givenName: "TestDRep", bio: "Test bio" }, +}; + +const VALID_PROPOSAL_ID = "b".repeat(64) + "#0"; + +// ─── Cross-Verification Tests ───────────────────────────────────────────────── + +describe("browser vs direct builder — identical tx calls", () => { + afterEach(() => { jest.restoreAllMocks(); }); + + it("setupProxy produces the same builder calls as buildProxySetupTx", async () => { + const provider = createMockProvider(); + + // Direct path — MeshTxInitiator constructor calls setNetwork; mirror it here + const { mesh: directMesh, calls: directCalls } = createMeshMock(provider); + (directMesh as any).setNetwork("preprod"); + buildProxySetupTx({ + txBuilder: directMesh as never, + network: 0, + walletUtxos: [LARGE_UTXO], + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + + // Browser path + const { mesh: browserMesh, calls: browserCalls } = createMeshMock(provider); + const contract = new MeshProxyContract( + { mesh: browserMesh, networkId: 0, wallet: {} as any }, + {}, + ); + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: [LARGE_UTXO], + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + await contract.setupProxy(); + + expect(browserCalls).toEqual(directCalls); + }); + + it("spendProxySimple produces the same builder calls as buildProxySpendTx", async () => { + // The contract's proxyAddress (setProxyAddress with undefined stakeCredential) may differ + // from deriveProxyScripts().proxyAddress (which falls back to DEFAULT_PROXY_STAKE_CREDENTIAL). + // Obtain the authoritative proxyAddress from the contract so both paths agree. + const tmpProvider = createMockProvider(); + const { mesh: tmpMesh } = createMeshMock(tmpProvider); + const tmpContract = new MeshProxyContract( + { mesh: tmpMesh, networkId: 0, wallet: {} as any }, + { paramUtxo: PARAM_UTXO }, + ); + const proxyAddress = tmpContract.proxyAddress!; + const authTokenPolicyId = tmpContract.getAuthTokenPolicyId(); + + const authTokenUtxo: UTxO = { + input: { txHash: "f".repeat(64), outputIndex: 0 }, + output: { + address: CHANGE_ADDRESS, + amount: [ + { unit: "lovelace", quantity: "5000000" }, + { unit: authTokenPolicyId, quantity: "1" }, + ], + }, + }; + const proxyUtxo: UTxO = { + input: { txHash: "a".repeat(63) + "b", outputIndex: 0 }, + output: { + address: proxyAddress, + amount: [{ unit: "lovelace", quantity: "5000000" }], + }, + }; + const walletUtxos = [authTokenUtxo, FIXTURE_COLLATERAL]; + const outputs = [{ address: CHANGE_ADDRESS, unit: "lovelace", amount: "2000000" }]; + + // Direct path — replicate the selection logic spendProxySimple applies + const scripts = deriveProxyScripts({ paramUtxo: PARAM_UTXO, network: 0 }); + const provider = createMockProvider(); + const { mesh: directMesh, calls: directCalls } = createMeshMock(provider); + (directMesh as any).setNetwork("preprod"); + const selectedProxyUtxos = selectProxyUtxosForOutputs([proxyUtxo], outputs, 500_000n); + const selectedAuth = selectAuthTokenUtxo(walletUtxos, scripts.authTokenId); + buildProxySpendTx({ + txBuilder: directMesh as never, + network: 0, + proxyAddress, + paramUtxo: PARAM_UTXO, + walletUtxos: [], + proxyUtxos: selectedProxyUtxos, + authTokenUtxo: selectedAuth, + collateral: FIXTURE_COLLATERAL, + outputs, + walletAddress: CHANGE_ADDRESS, + }); + + // Browser path + const browserProvider = createMockProvider(); + (browserProvider.fetchAddressUTxOs as any).mockResolvedValue([proxyUtxo]); + const { mesh: browserMesh, calls: browserCalls } = createMeshMock(browserProvider); + const contract = new MeshProxyContract( + { mesh: browserMesh, networkId: 0, wallet: {} as any }, + { paramUtxo: PARAM_UTXO }, + ); + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: walletUtxos, + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + await contract.spendProxySimple(outputs); + + expect(browserCalls).toEqual(directCalls); + }); + + it("manageProxyDrep(register) produces the same builder calls as buildProxyDRepCertificateTx", async () => { + const scripts = deriveProxyScripts({ paramUtxo: PARAM_UTXO, network: 0 }); + + const authTokenUtxo: UTxO = { + input: { txHash: "f".repeat(64), outputIndex: 0 }, + output: { + address: CHANGE_ADDRESS, + amount: [ + { unit: "lovelace", quantity: "600000000" }, // 600 ADA covers 505 ADA deposit + { unit: scripts.authTokenId, quantity: "1" }, + ], + }, + }; + const walletUtxos = [authTokenUtxo, FIXTURE_COLLATERAL]; + + // Direct path + const provider = createMockProvider(); + const { mesh: directMesh, calls: directCalls } = createMeshMock(provider); + (directMesh as any).setNetwork("preprod"); + buildProxyDRepCertificateTx({ + txBuilder: directMesh as never, + network: 0, + paramUtxo: PARAM_UTXO, + walletUtxos, + authTokenUtxo, + collateral: FIXTURE_COLLATERAL, + walletAddress: CHANGE_ADDRESS, + action: "register", + anchorUrl: ANCHOR_URL, + anchorJson: ANCHOR_JSON, + }); + + // Browser path + const { mesh: browserMesh, calls: browserCalls } = createMeshMock(provider); + const contract = new MeshProxyContract( + { mesh: browserMesh, networkId: 0, wallet: {} as any }, + { paramUtxo: PARAM_UTXO }, + ); + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: walletUtxos, + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + await contract.manageProxyDrep("register", ANCHOR_URL, ANCHOR_JSON); + + expect(browserCalls).toEqual(directCalls); + }); + + it("voteProxyDrep produces the same builder calls as buildProxyVoteTx", async () => { + const scripts = deriveProxyScripts({ paramUtxo: PARAM_UTXO, network: 0 }); + + const authTokenUtxo: UTxO = { + input: { txHash: "f".repeat(64), outputIndex: 0 }, + output: { + address: CHANGE_ADDRESS, + amount: [ + { unit: "lovelace", quantity: "5000000" }, + { unit: scripts.authTokenId, quantity: "1" }, + ], + }, + }; + const walletUtxos = [authTokenUtxo, FIXTURE_COLLATERAL]; + const votes = [{ proposalId: VALID_PROPOSAL_ID, voteKind: "Yes" as const }]; + + // Direct path + const provider = createMockProvider(); + const { mesh: directMesh, calls: directCalls } = createMeshMock(provider); + (directMesh as any).setNetwork("preprod"); + buildProxyVoteTx({ + txBuilder: directMesh as never, + network: 0, + paramUtxo: PARAM_UTXO, + walletUtxos, + authTokenUtxo, + collateral: FIXTURE_COLLATERAL, + walletAddress: CHANGE_ADDRESS, + votes, + }); + + // Browser path + const { mesh: browserMesh, calls: browserCalls } = createMeshMock(provider); + const contract = new MeshProxyContract( + { mesh: browserMesh, networkId: 0, wallet: {} as any }, + { paramUtxo: PARAM_UTXO }, + ); + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: walletUtxos, + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + await contract.voteProxyDrep(votes); + + expect(browserCalls).toEqual(directCalls); + }); +}); diff --git a/src/__tests__/proxy-utxo-selection.test.ts b/src/__tests__/proxy-utxo-selection.test.ts new file mode 100644 index 00000000..4545dcbf --- /dev/null +++ b/src/__tests__/proxy-utxo-selection.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "@jest/globals"; +import type { UTxO } from "@meshsdk/core"; +import { + selectProxyUtxosForOutputs, + selectAuthTokenUtxo, +} from "@/lib/proxy/utxoUtils"; + +const AUTH_POLICY_ID = "a".repeat(56); +const TOKEN_UNIT = "d".repeat(56) + "6d79546f6b656e"; + +function mkUtxo( + txHash: string, + outputIndex: number, + lovelace: string, + token?: { unit: string; quantity: string }, +): UTxO { + return { + input: { txHash, outputIndex }, + output: { + address: "addr_test1_proxy", + amount: token + ? [{ unit: "lovelace", quantity: lovelace }, token] + : [{ unit: "lovelace", quantity: lovelace }], + }, + }; +} + +// ─── selectProxyUtxosForOutputs ─────────────────────────────────────────────── + +describe("selectProxyUtxosForOutputs", () => { + it("returns the single UTxO that exactly covers the required amount", () => { + const utxo = mkUtxo("a".repeat(64), 0, "2000000"); + const selected = selectProxyUtxosForOutputs([utxo], [{ unit: "lovelace", amount: "2000000" }]); + expect(selected).toEqual([utxo]); + }); + + it("greedily selects the single large UTxO instead of two smaller ones", () => { + const small = mkUtxo("a".repeat(64), 0, "1000000"); + const large = mkUtxo("b".repeat(64), 0, "5000000"); + // 3 ADA required — large alone covers it; small alone does not + const selected = selectProxyUtxosForOutputs( + [small, large], + [{ unit: "lovelace", amount: "3000000" }], + ); + expect(selected).toEqual([large]); + }); + + it("selects multiple UTxOs when feeBuffer pushes total above a single UTxO", () => { + const a = mkUtxo("a".repeat(64), 0, "1500000"); + const b = mkUtxo("b".repeat(64), 0, "1500000"); + // 1.5 ADA output + 0.5 ADA fee buffer = 2.0 ADA required; each UTxO is 1.5 ADA + const selected = selectProxyUtxosForOutputs( + [a, b], + [{ unit: "lovelace", amount: "1500000" }], + 500_000n, + ); + expect(selected).toHaveLength(2); + expect(selected).toContain(a); + expect(selected).toContain(b); + }); + + it("handles multi-asset outputs by selecting the UTxO holding the token", () => { + const lovelaceOnly = mkUtxo("a".repeat(64), 0, "5000000"); + const withToken = mkUtxo("b".repeat(64), 0, "2000000", { unit: TOKEN_UNIT, quantity: "1" }); + const selected = selectProxyUtxosForOutputs( + [lovelaceOnly, withToken], + [{ unit: TOKEN_UNIT, amount: "1" }], + ); + expect(selected).toContain(withToken); + expect(selected).not.toContain(lovelaceOnly); + }); + + it("selects both a lovelace UTxO and a token UTxO when both are needed", () => { + const lovelaceUtxo = mkUtxo("a".repeat(64), 0, "3000000"); + const tokenUtxo = mkUtxo("b".repeat(64), 0, "2000000", { unit: TOKEN_UNIT, quantity: "1" }); + const outputs = [ + { unit: "lovelace", amount: "3000000" }, + { unit: TOKEN_UNIT, amount: "1" }, + ]; + const selected = selectProxyUtxosForOutputs([lovelaceUtxo, tokenUtxo], outputs); + expect(selected).toContain(lovelaceUtxo); + expect(selected).toContain(tokenUtxo); + }); + + it("throws when proxy balance is insufficient to cover outputs", () => { + const utxo = mkUtxo("a".repeat(64), 0, "1000000"); + expect(() => + selectProxyUtxosForOutputs([utxo], [{ unit: "lovelace", amount: "5000000" }]), + ).toThrow("Unable to select proxy UTxOs for requested outputs"); + }); + + it("throws when the token is not held by any proxy UTxO", () => { + const utxo = mkUtxo("a".repeat(64), 0, "5000000"); + expect(() => + selectProxyUtxosForOutputs([utxo], [{ unit: TOKEN_UNIT, amount: "1" }]), + ).toThrow("Unable to select proxy UTxOs for requested outputs"); + }); +}); + +// ─── selectAuthTokenUtxo ────────────────────────────────────────────────────── + +describe("selectAuthTokenUtxo", () => { + it("returns the UTxO that holds the auth token", () => { + const noToken = mkUtxo("a".repeat(64), 0, "5000000"); + const withToken = mkUtxo("b".repeat(64), 0, "2000000", { unit: AUTH_POLICY_ID, quantity: "1" }); + const result = selectAuthTokenUtxo([noToken, withToken], AUTH_POLICY_ID); + expect(result).toEqual(withToken); + }); + + it("prefers the UTxO with more lovelace when multiple candidates exist", () => { + const rich = mkUtxo("a".repeat(64), 0, "10000000", { unit: AUTH_POLICY_ID, quantity: "1" }); + const poor = mkUtxo("b".repeat(64), 0, "2000000", { unit: AUTH_POLICY_ID, quantity: "1" }); + const result = selectAuthTokenUtxo([poor, rich], AUTH_POLICY_ID); + expect(result).toEqual(rich); + }); + + it("breaks lovelace ties by lexicographic txHash order", () => { + const first = mkUtxo("a".repeat(64), 0, "5000000", { unit: AUTH_POLICY_ID, quantity: "1" }); + const second = mkUtxo("b".repeat(64), 0, "5000000", { unit: AUTH_POLICY_ID, quantity: "1" }); + const result = selectAuthTokenUtxo([second, first], AUTH_POLICY_ID); + expect(result).toEqual(first); // "aaa..." < "bbb..." + }); + + it("skips UTxOs listed in blockedUtxoRefs", () => { + const blocked = mkUtxo("a".repeat(64), 0, "10000000", { unit: AUTH_POLICY_ID, quantity: "1" }); + const free = mkUtxo("b".repeat(64), 0, "2000000", { unit: AUTH_POLICY_ID, quantity: "1" }); + const result = selectAuthTokenUtxo( + [blocked, free], + AUTH_POLICY_ID, + [{ txHash: "a".repeat(64), outputIndex: 0 }], + ); + expect(result).toEqual(free); + }); + + it("throws when no auth token UTxO is available", () => { + const noToken = mkUtxo("a".repeat(64), 0, "5000000"); + expect(() => selectAuthTokenUtxo([noToken], AUTH_POLICY_ID)).toThrow( + "No AuthToken found", + ); + }); + + it("throws when all auth token UTxOs are blocked", () => { + const utxo = mkUtxo("a".repeat(64), 0, "5000000", { unit: AUTH_POLICY_ID, quantity: "1" }); + expect(() => + selectAuthTokenUtxo( + [utxo], + AUTH_POLICY_ID, + [{ txHash: "a".repeat(64), outputIndex: 0 }], + ), + ).toThrow("No AuthToken found"); + }); +}); diff --git a/src/__tests__/proxyCiPreflight.test.ts b/src/__tests__/proxyCiPreflight.test.ts index 89fc9345..d4117bfe 100644 --- a/src/__tests__/proxyCiPreflight.test.ts +++ b/src/__tests__/proxyCiPreflight.test.ts @@ -310,7 +310,8 @@ describe("proxy full lifecycle hygiene", () => { isActive: true, }; - function createHygieneDeps(requestJsonMock: ReturnType) { + type RequestJsonMock = jest.Mock<() => Promise<{ status: number; data: unknown }>>; + function createHygieneDeps(requestJsonMock: RequestJsonMock) { return { requestJson: requestJsonMock, authenticateBot: jest.fn(async () => "token"), @@ -346,7 +347,7 @@ describe("proxy full lifecycle hygiene", () => { it("cleans and finalizes an active proxy that is ready to burn", async () => { const requestJsonMock = jest - .fn() + .fn<() => Promise<{ status: number; data: unknown }>>() .mockResolvedValueOnce({ status: 200, data: [proxy] }) .mockResolvedValueOnce({ status: 200, data: { active: false, dRepId: "drep1proxy" } }) .mockResolvedValueOnce({ @@ -395,7 +396,7 @@ describe("proxy full lifecycle hygiene", () => { it("runs a sweep pass before the burn pass when proxy UTxOs remain", async () => { const requestJsonMock = jest - .fn() + .fn<() => Promise<{ status: number; data: unknown }>>() .mockResolvedValueOnce({ status: 200, data: [proxy] }) .mockResolvedValueOnce({ status: 200, data: { active: false, dRepId: "drep1proxy" } }) .mockResolvedValueOnce({ @@ -424,7 +425,7 @@ describe("proxy full lifecycle hygiene", () => { it("deregisters an active proxy DRep before cleanup", async () => { const requestJsonMock = jest - .fn() + .fn<() => Promise<{ status: number; data: unknown }>>() .mockResolvedValueOnce({ status: 200, data: [proxy] }) .mockResolvedValueOnce({ status: 200, data: { active: true, dRepId: "drep1proxy" } }) .mockResolvedValueOnce({ status: 201, data: { transaction: { id: "tx-drep" } } }) diff --git a/src/__tests__/proxyCleanup.bot.test.ts b/src/__tests__/proxyCleanup.bot.test.ts index bdc8bd40..c065d42a 100644 --- a/src/__tests__/proxyCleanup.bot.test.ts +++ b/src/__tests__/proxyCleanup.bot.test.ts @@ -17,6 +17,8 @@ const resolveUtxoRefsFromChainMock: jest.Mock = jest.fn(); const resolveCollateralRefFromChainMock: jest.Mock = jest.fn(); const resolveSingleUtxoRefFromChainMock: jest.Mock = jest.fn(); const requireAuthTokenUtxoMock: jest.Mock = jest.fn(); +const loadBlockedUtxoRefsForWalletMock: jest.Mock = jest.fn(); +const selectAuthTokenUtxoMock: jest.Mock = jest.fn(); const buildProxyCleanupSweepTxMock: jest.Mock = jest.fn(); const buildProxyCleanupTxMock: jest.Mock = jest.fn(); const deriveProxyScriptsMock: jest.Mock = jest.fn(); @@ -85,10 +87,16 @@ jest.mock("@/lib/server/resolveUtxoRefsFromChain", () => ({ jest.mock("@/lib/server/proxyUtxos", () => ({ __esModule: true, requireAuthTokenUtxo: requireAuthTokenUtxoMock, + loadBlockedUtxoRefsForWallet: loadBlockedUtxoRefsForWalletMock, resolveCollateralRefFromChain: resolveCollateralRefFromChainMock, resolveSingleUtxoRefFromChain: resolveSingleUtxoRefFromChainMock, }), { virtual: true }); +jest.mock("@/lib/proxy/utxoUtils", () => ({ + __esModule: true, + selectAuthTokenUtxo: selectAuthTokenUtxoMock, +}), { virtual: true }); + jest.mock("@/lib/server/createPendingMultisigTransaction", () => ({ __esModule: true, createPendingMultisigTransaction: createPendingMultisigTransactionMock, @@ -138,6 +146,8 @@ beforeEach(() => { (resolveUtxoRefsFromChainMock as any).mockResolvedValue({ utxos: [{ input: { txHash: "bb", outputIndex: 1 } }] }); (resolveCollateralRefFromChainMock as any).mockResolvedValue({ collateral: { input: { txHash: "dd", outputIndex: 3 } } }); requireAuthTokenUtxoMock.mockReturnValue({ input: { txHash: "bb", outputIndex: 1 } }); + (loadBlockedUtxoRefsForWalletMock as any).mockResolvedValue([]); + selectAuthTokenUtxoMock.mockReturnValue({ input: { txHash: "bb", outputIndex: 1 } }); deriveProxyScriptsMock.mockReturnValue({ authTokenId: proxy.authTokenId, proxyAddress: proxy.proxyAddress, diff --git a/src/__tests__/proxyDRepInfo.test.ts b/src/__tests__/proxyDRepInfo.test.ts index 0bd3619a..5f33c700 100644 --- a/src/__tests__/proxyDRepInfo.test.ts +++ b/src/__tests__/proxyDRepInfo.test.ts @@ -8,7 +8,7 @@ const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) = const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, botId: string) => boolean>(); const verifyJwtMock: jest.Mock = jest.fn(); const isBotJwtMock: jest.Mock = jest.fn(); -const authorizeProxyReadForV1Mock: jest.Mock = jest.fn(); +const authorizeProxyReadForV1Mock = jest.fn<() => Promise>(); const loadActiveProxyForWalletMock: jest.Mock = jest.fn(); const deriveProxyScriptsMock: jest.Mock = jest.fn(); diff --git a/src/__tests__/proxyTxBuilders.test.ts b/src/__tests__/proxyTxBuilders.test.ts index 1cca2d4b..f58ce062 100644 --- a/src/__tests__/proxyTxBuilders.test.ts +++ b/src/__tests__/proxyTxBuilders.test.ts @@ -7,7 +7,7 @@ import { buildProxyVoteTx, buildProxySetupTx, DEFAULT_PROXY_SETUP_LOVELACE, -} from "@/lib/server/proxyTxBuilders"; +} from "@/lib/proxy/txBuilders"; const mkUtxo = ( address: string, diff --git a/src/__tests__/tx-builders/proxy.test.ts b/src/__tests__/tx-builders/proxy.test.ts index 0372afa2..dbc45187 100644 --- a/src/__tests__/tx-builders/proxy.test.ts +++ b/src/__tests__/tx-builders/proxy.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect, jest } from "@jest/globals"; +import { describe, it, expect, jest, afterEach } from "@jest/globals"; import type { UTxO } from "@meshsdk/core"; +import { hashDrepAnchor } from "@meshsdk/core"; import { MeshProxyContract } from "@/components/multisig/proxy/offchain"; +import * as txBuilders from "@/lib/proxy/txBuilders"; import { createMockProvider } from "./mockProvider"; import { FIXTURE_UTXOS, @@ -100,9 +102,15 @@ const LARGE_UTXO: UTxO = { }, }; +const ANCHOR_JSON = { + "@context": "https://github.com/cardano-foundation/CIPs/blob/master/CIP-0119/README.md", + hashAlgorithm: "blake2b-256", + body: { givenName: "TestDRep", bio: "Test bio" }, +}; + const ANCHOR = { anchorUrl: "https://example.com/drep.json", - anchorDataHash: "0".repeat(64), + anchorJson: ANCHOR_JSON, }; const VALID_PROPOSAL_ID = "b".repeat(64) + "#0"; @@ -203,12 +211,12 @@ describe("MeshProxyContract.setupProxy", () => { describe("MeshProxyContract.manageProxyDrep", () => { it("register calls drepRegistrationCertificate with drepId and anchor", async () => { const { contract, calls } = makeManageContract(); - await contract.manageProxyDrep("register", ANCHOR.anchorUrl, ANCHOR.anchorDataHash); + await contract.manageProxyDrep("register", ANCHOR.anchorUrl, ANCHOR.anchorJson); 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 }); + expect(certCall!.args[1]).toEqual({ anchorUrl: ANCHOR.anchorUrl, anchorDataHash: hashDrepAnchor(ANCHOR.anchorJson) }); }); it("deregister calls drepDeregistrationCertificate with drepId", async () => { @@ -222,26 +230,26 @@ describe("MeshProxyContract.manageProxyDrep", () => { it("update calls drepUpdateCertificate with drepId and anchor", async () => { const { contract, calls } = makeManageContract(); - await contract.manageProxyDrep("update", ANCHOR.anchorUrl, ANCHOR.anchorDataHash); + await contract.manageProxyDrep("update", ANCHOR.anchorUrl, ANCHOR.anchorJson); 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 }); + expect(certCall!.args[1]).toEqual({ anchorUrl: ANCHOR.anchorUrl, anchorDataHash: hashDrepAnchor(ANCHOR.anchorJson) }); }); it("register without anchor throws", async () => { const { contract } = makeManageContract(); await expect( contract.manageProxyDrep("register"), - ).rejects.toThrow("Anchor URL and hash are required"); + ).rejects.toThrow("Anchor URL and JSON are required"); }); it("update without anchor throws", async () => { const { contract } = makeManageContract(); await expect( contract.manageProxyDrep("update"), - ).rejects.toThrow("Anchor URL and hash are required"); + ).rejects.toThrow("Anchor URL and JSON are required"); }); it("throws when auth token is absent from wallet UTxOs", async () => { @@ -343,6 +351,77 @@ describe("MeshProxyContract.voteProxyDrep", () => { const { contract } = makeManageContract(); await expect( contract.voteProxyDrep([{ proposalId: "invalid-id", voteKind: "Yes" }]), - ).rejects.toThrow("Invalid proposal ID format"); + ).rejects.toThrow("Invalid proposalId format"); + }); +}); + +// ─── Builder Delegation Tests ───────────────────────────────────────────────── + +describe("MeshProxyContract — builder delegation", () => { + afterEach(() => { jest.restoreAllMocks(); }); + + it("setupProxy delegates to buildProxySetupTx", async () => { + const spy = jest.spyOn(txBuilders, "buildProxySetupTx"); + const { contract } = makeSetupContract(); + jest.spyOn(contract as any, "getWalletInfoForTx").mockResolvedValue({ + utxos: [LARGE_UTXO], + walletAddress: CHANGE_ADDRESS, + collateral: FIXTURE_COLLATERAL, + }); + + await contract.setupProxy(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ + network: 0, + walletAddress: CHANGE_ADDRESS, + walletUtxos: [LARGE_UTXO], + })); + }); + + it("spendProxySimple delegates to buildProxySpendTx", async () => { + const spy = jest.spyOn(txBuilders, "buildProxySpendTx"); + const { contract } = makeManageContract(); + + await contract.spendProxySimple([ + { address: CHANGE_ADDRESS, unit: "lovelace", amount: "1000000" }, + ]); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ + network: 0, + walletAddress: CHANGE_ADDRESS, + walletUtxos: [], + })); + }); + + it("manageProxyDrep delegates to buildProxyDRepCertificateTx", async () => { + const spy = jest.spyOn(txBuilders, "buildProxyDRepCertificateTx"); + const { contract } = makeManageContract(); + + await contract.manageProxyDrep("deregister"); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ + network: 0, + action: "deregister", + walletAddress: CHANGE_ADDRESS, + })); + }); + + it("voteProxyDrep delegates to buildProxyVoteTx", async () => { + const spy = jest.spyOn(txBuilders, "buildProxyVoteTx"); + const { contract } = makeManageContract(); + + await contract.voteProxyDrep([ + { proposalId: VALID_PROPOSAL_ID, voteKind: "Yes" }, + ]); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ + network: 0, + votes: [{ proposalId: VALID_PROPOSAL_ID, voteKind: "Yes" }], + walletAddress: CHANGE_ADDRESS, + })); }); }); diff --git a/src/components/multisig/proxy/ProxyControl.tsx b/src/components/multisig/proxy/ProxyControl.tsx index 806b61df..e575086f 100644 --- a/src/components/multisig/proxy/ProxyControl.tsx +++ b/src/components/multisig/proxy/ProxyControl.tsx @@ -11,10 +11,6 @@ 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"; @@ -123,23 +119,6 @@ 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); @@ -184,14 +163,9 @@ 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: freeUtxos, walletAddress: appWallet.address }; - }, [appWallet?.address, blockedUtxoRefs, network]); + return { utxos, walletAddress: appWallet.address }; + }, [appWallet?.address, network]); // Initialize proxy contract const contractInitializedRef = useRef(false); @@ -202,7 +176,7 @@ export default function ProxyControl() { // Only initialize once if (!contractInitializedRef.current) { try { - const txBuilder = getTxBuilder(network, true); + const txBuilder = getTxBuilder(network); const contract = new MeshProxyContract( { mesh: txBuilder, @@ -641,7 +615,7 @@ export default function ProxyControl() { const selectedProxyContract = new MeshProxyContract( { - mesh: getTxBuilder(network, true), + mesh: getTxBuilder(network), wallet: activeWallet, networkId: network, }, @@ -654,12 +628,7 @@ export default function ProxyControl() { // Pass multisig inputs to spend as well const { utxos, walletAddress } = await getMsInputs(); - const txHex = await selectedProxyContract.spendProxySimple( - validOutputs, - utxos, - walletAddress, - blockedUtxoRefs, - ); + const txHex = await selectedProxyContract.spendProxySimple(validOutputs, utxos, walletAddress); if (appWallet?.scriptCbor) { await newTransaction({ txBuilder: txHex, diff --git a/src/components/multisig/proxy/offchain.ts b/src/components/multisig/proxy/offchain.ts index c29aca78..6864a8f9 100644 --- a/src/components/multisig/proxy/offchain.ts +++ b/src/components/multisig/proxy/offchain.ts @@ -1,4 +1,4 @@ -import { mConStr0, mOutputReference } from "@meshsdk/common"; +import { mOutputReference } from "@meshsdk/common"; import { resolveScriptHash, serializePlutusScript, @@ -6,29 +6,23 @@ import { resolveScriptHashDRepId, } from "@meshsdk/core"; 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"; import blueprint from "./aiken-workspace/plutus.json"; -/** - * Mesh Plutus NFT contract class - * - * This NFT minting script enables users to mint NFTs with an automatically incremented index, which increases by one for each newly minted NFT. - * - * To facilitate this process, the first step is to set up a one-time minting policy by minting an oracle token. This oracle token is essential as it holds the current state and index of the NFTs, acting as a reference for the minting sequence. - * - * With each new NFT minted, the token index within the oracle is incremented by one, ensuring a consistent and orderly progression in the numbering of the NFTs. - */ +import { + buildProxySetupTx, + buildProxySpendTx, + buildProxyDRepCertificateTx, + buildProxyVoteTx, + deriveProxyScripts, +} from "@/lib/proxy/txBuilders"; +import { + selectProxyUtxosForOutputs, + selectAuthTokenUtxo, +} from "@/lib/proxy/utxoUtils"; + // Cache for DRep status to avoid multiple API calls const drepStatusCache = new Map(); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes @@ -104,6 +98,26 @@ export class MeshProxyContract extends MeshTxInitiator { } } + private _resolveWalletInputs = async ( + msUtxos?: UTxO[], + msWalletAddress?: string, + ) => { + if (this.msCbor && (!msUtxos || !msWalletAddress)) { + throw new Error( + "No UTxOs and wallet address for multisig script cbor found", + ); + } + const walletInfo = await this.getWalletInfoForTx(); + if (this.msCbor && msUtxos && msWalletAddress) { + return { + utxos: msUtxos, + walletAddress: msWalletAddress, + collateral: walletInfo.collateral, + }; + } + return walletInfo; + }; + /** * Set up a proxy address with fixed amount of 10 auth tokens, that will be sent to the owner multisig * Moving an auth token unlocks the proxy address. @@ -116,167 +130,79 @@ export class MeshProxyContract extends MeshTxInitiator { * ``` */ setupProxy = async (msUtxos?: UTxO[], msWalletAddress?: string) => { - if (this.msCbor && !msUtxos && !msWalletAddress) { - throw new Error( - "No UTxOs and wallet address for multisig script cbor found", - ); - } + const { utxos, walletAddress, collateral } = + await this._resolveWalletInputs(msUtxos, msWalletAddress); - const walletInfo = await this.getWalletInfoForTx(); - let { utxos, walletAddress } = walletInfo; - const { collateral } = walletInfo; - - if (this.msCbor && msUtxos && msWalletAddress) { - utxos = msUtxos; - walletAddress = msWalletAddress; - } - - //look for, get and set a paramUtxo for minting the AuthToken - if (!utxos || utxos.length <= 0) { - throw new Error("No UTxOs found"); - } - const paramUtxo = utxos?.find((utxo) => - utxo.output.amount.some( - (asset) => asset.unit === "lovelace" && Number(asset.quantity) >= 20000000, - ), - ); - if (!paramUtxo) { - throw new Error( - "Insufficicient balance. Create one utxo holding at Least 20 ADA.", - ); - } - this.paramUtxo = paramUtxo.input; - - //Set proxyAddress depending on the paramUtxo - const proxyAddress = this.setProxyAddress(); - if (!proxyAddress) { - throw new Error("Proxy address not set"); - } - - //prepare AuthToken mint - const policyId = this.getAuthTokenPolicyId(); - const tokenName = ""; - - // Try completing the transaction step by step - const tx = this.mesh.txIn( - paramUtxo.input.txHash, - paramUtxo.input.outputIndex, - paramUtxo.output.amount, - paramUtxo.output.address, - ); - // Add the multisig script cbor if it exists - if (this.msCbor) { - tx.txInScript(this.msCbor); - } - - tx.mintPlutusScriptV3() - .mint("10", policyId, tokenName) - .mintingScript(this.getAuthTokenCbor()) - .mintRedeemerValue(mConStr0([])) - .txOut(proxyAddress, [{ unit: "lovelace", quantity: "1000000" }]); + const result = buildProxySetupTx({ + txBuilder: this.mesh, + network: this.networkId, + walletUtxos: utxos, + walletAddress, + collateral, + multisigScriptCbor: this.msCbor, + stakeCredential: this.stakeCredential, + }); - for (let i = 0; i < 10; i++) { - tx.txOut(walletAddress, [{ unit: policyId, quantity: "1" }]); - } + this.paramUtxo = result.paramUtxo; + this.setProxyAddress(); - tx.txInCollateral( - collateral.input.txHash, - collateral.input.outputIndex, - collateral.output.amount, - collateral.output.address, - ).changeAddress(walletAddress); - - const txHex = tx; - - return { - tx: txHex, - paramUtxo: paramUtxo.input, - authTokenId: policyId, - proxyAddress: proxyAddress, - }; + return { tx: this.mesh, ...result }; }; spendProxySimple = async ( outputs: { address: string; unit: string; amount: string }[], msUtxos?: UTxO[], msWalletAddress?: string, - blockedUtxoRefs: UtxoRef[] = [], ) => { - if (this.msCbor && !msUtxos && !msWalletAddress) { - throw new Error( - "No UTxOs and wallet address for multisig script cbor found", - ); - } - const walletInfo = await this.getWalletInfoForTx(); - let { utxos, walletAddress } = walletInfo; - const { collateral } = walletInfo; - if (this.msCbor && msUtxos && msWalletAddress) { - utxos = msUtxos; - walletAddress = msWalletAddress; - } - if (!utxos || utxos.length <= 0) { - throw new Error("No UTxOs found"); - } - if (!walletAddress) { - throw new Error("No wallet address found"); - } - if (!collateral) { - throw new Error("No collateral found"); - } + const { utxos, walletAddress, collateral } = + await this._resolveWalletInputs(msUtxos, msWalletAddress); + 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 policyIdAT = resolveScriptHash(this.getAuthTokenCbor(), "V3"); - const authTokenUtxo = selectFreeAuthTokenUtxo( - utxos, - policyIdAT, - blockedUtxoRefs, - ); - if ("error" in authTokenUtxo) { - throw new Error(authTokenUtxo.error); - } - const proxyUtxos = await blockchainProvider.fetchAddressUTxOs( this.proxyAddress, ); - const selectedProxyUtxos = selectProxyUtxosForOutputs({ + + const scripts = deriveProxyScripts({ + paramUtxo: this.paramUtxo, + network: this.networkId, + stakeCredential: this.stakeCredential, + }); + const selectedProxyUtxos = selectProxyUtxosForOutputs( proxyUtxos, outputs, - }); - if ("error" in selectedProxyUtxos) { - throw new Error(selectedProxyUtxos.error); - } + 500_000n, // browser fee buffer preserved + ); + const authTokenUtxo = selectAuthTokenUtxo(utxos, scripts.authTokenId); - const txBuilder = getTxBuilder(this.networkId, true); buildProxySpendTx({ - txBuilder, + txBuilder: this.mesh, network: this.networkId, proxyAddress: this.proxyAddress, paramUtxo: this.paramUtxo, - walletUtxos: [authTokenUtxo], + walletUtxos: [], // browser passes empty per D6 resolution proxyUtxos: selectedProxyUtxos, authTokenUtxo, collateral, outputs, walletAddress, multisigScriptCbor: this.msCbor, + stakeCredential: this.stakeCredential, }); - return txBuilder; + return this.mesh; }; manageProxyDrep = async ( action: "register" | "deregister" | "update", anchorUrl?: string, - anchorHash?: string, + anchorJson?: object, msUtxos?: UTxO[], msWalletAddress?: string, ) => { @@ -285,152 +211,60 @@ export class MeshProxyContract extends MeshTxInitiator { } if ( (action === "register" || action === "update") && - (!anchorUrl || !anchorHash) + (!anchorUrl || !anchorJson) ) { throw new Error( - "Anchor URL and hash are required for register and update actions", - ); - } - if (this.msCbor && !msUtxos && !msWalletAddress) { - throw new Error( - "No UTxOs and wallet address for multisig script cbor found", - ); - } - const walletInfo2 = await this.getWalletInfoForTx(); - let { utxos, walletAddress } = walletInfo2; - const { collateral } = walletInfo2; - // If multisig inputs are provided, use them instead of the wallet inputs - if (this.msCbor && msUtxos && msWalletAddress) { - utxos = msUtxos; - walletAddress = msWalletAddress; - } - if (!utxos || utxos.length <= 0) { - throw new Error("No UTxOs found"); - } - if (!walletAddress) { - throw new Error("No wallet address found"); - } - if (!collateral) { - throw new Error("No collateral found"); - } - if (this.proxyAddress === undefined) { - throw new Error("Proxy address not set. Please setupProxy first."); - } - - const blockchainProvider = this.mesh.fetcher; - if (!blockchainProvider) { - throw new Error("Blockchain provider not found"); - } - - const paramScriptAT = this.getAuthTokenCbor(); - const policyIdAT = resolveScriptHash(paramScriptAT, "V3"); - const authTokenUtxos = utxos.filter((utxo) => - utxo.output.amount.some((asset) => asset.unit === policyIdAT), - ); - - 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"); - } - - const proxyCbor = this.getProxyCbor(); - const proxyScriptHash = resolveScriptHash(proxyCbor, "V3"); - const drepId = resolveScriptHashDRepId(proxyScriptHash); - - const txHex = this.mesh; - txHex.txIn( - authTokenUtxo.input.txHash, - authTokenUtxo.input.outputIndex, - authTokenUtxo.output.amount, - authTokenUtxo.output.address, - ); - - if (this.msCbor) { - txHex.txInScript(this.msCbor); - } - txHex.txInCollateral( - collateral.input.txHash, - collateral.input.outputIndex, - collateral.output.amount, - collateral.output.address, - ); - - // add more utxo inputs until the required amount is reached, use utxos list. - // Register requires 505 ADA, deregister and update only need 2 ADA - const requiredAmount = - action === "register" ? BigInt(505000000) : BigInt(2000000); - let totalAmount = BigInt(0); - for (const utxo of utxos) { - if (totalAmount >= requiredAmount) { - break; - } - txHex.txIn( - utxo.input.txHash, - utxo.input.outputIndex, - utxo.output.amount, - utxo.output.address, - ); - if (this.msCbor) { - txHex.txInScript(this.msCbor); - } - totalAmount += BigInt( - (utxo.output.amount.find((asset: { unit: string; quantity: string }) => asset.unit === "lovelace") - ?.quantity) ?? "0", + "Anchor URL and JSON are required for register and update actions", ); } - txHex.txOut(walletAddress, [{ unit: policyIdAT, quantity: "1" }]); + const { utxos, walletAddress, collateral } = + await this._resolveWalletInputs(msUtxos, msWalletAddress); - // Add the appropriate certificate based on action - if (action === "register") { - txHex.drepRegistrationCertificate(drepId, { - anchorUrl: anchorUrl!, - anchorDataHash: anchorHash!, - }); - } else if (action === "deregister") { - txHex.drepDeregistrationCertificate(drepId); - } else if (action === "update") { - txHex.drepUpdateCertificate(drepId, { - anchorUrl: anchorUrl!, - anchorDataHash: anchorHash!, - }); - } + const scripts = deriveProxyScripts({ + paramUtxo: this.paramUtxo, + network: this.networkId, + stakeCredential: this.stakeCredential, + }); + const authTokenUtxo = selectAuthTokenUtxo(utxos, scripts.authTokenId); - txHex - .certificateScript(this.getProxyCbor(), "V3") - .certificateRedeemerValue(mConStr0([])) - .changeAddress(walletAddress); + buildProxyDRepCertificateTx({ + txBuilder: this.mesh, + network: this.networkId, + paramUtxo: this.paramUtxo, + walletUtxos: utxos, + authTokenUtxo, + collateral, + walletAddress, + action, + anchorUrl, + anchorJson, + multisigScriptCbor: this.msCbor, + stakeCredential: this.stakeCredential, + }); - return txHex; + return this.mesh; }; /** * Register a proxy DRep * * @param anchorUrl - URL for the DRep metadata - * @param anchorHash - Hash of the DRep metadata + * @param anchorJson - Raw JSON-LD metadata object (hash is computed internally) * @param msUtxos - Optional multisig UTxOs * @param msWalletAddress - Optional multisig wallet address * @returns - Transaction hex for signing */ registerProxyDrep = async ( anchorUrl: string, - anchorHash: string, + anchorJson: object, msUtxos?: UTxO[], msWalletAddress?: string, ) => { return this.manageProxyDrep( "register", anchorUrl, - anchorHash, + anchorJson, msUtxos, msWalletAddress, ); @@ -457,21 +291,21 @@ export class MeshProxyContract extends MeshTxInitiator { * Update a proxy DRep * * @param anchorUrl - URL for the DRep metadata - * @param anchorHash - Hash of the DRep metadata + * @param anchorJson - Raw JSON-LD metadata object (hash is computed internally) * @param msUtxos - Optional multisig UTxOs * @param msWalletAddress - Optional multisig wallet address * @returns - Transaction hex for signing */ updateProxyDrep = async ( anchorUrl: string, - anchorHash: string, + anchorJson: object, msUtxos?: UTxO[], msWalletAddress?: string, ) => { return this.manageProxyDrep( "update", anchorUrl, - anchorHash, + anchorJson, msUtxos, msWalletAddress, ); @@ -534,28 +368,28 @@ export class MeshProxyContract extends MeshTxInitiator { getDrepStatus = async (forceRefresh = false) => { const drepId = this.getDrepId(); - + // Check cache first const cached = drepStatusCache.get(drepId); if (!forceRefresh && cached && (Date.now() - cached.timestamp) < CACHE_DURATION) { return cached.data; } - + if (!this.mesh.fetcher) { throw new Error("Blockchain provider not found"); } - + try { const drepStatus = await this.mesh.fetcher.get( `/governance/dreps/${drepId}`, ); - + // Cache the successful result drepStatusCache.set(drepId, { data: drepStatus, timestamp: Date.now() }); - + return drepStatus; } catch (error: unknown) { // Parse the error if it's a stringified JSON @@ -567,12 +401,12 @@ export class MeshProxyContract extends MeshTxInitiator { // If parsing fails, use the original error } } - + // Handle specific error cases - check multiple possible 404 indicators const errorObj = error as Record; const parsedObj = parsedError as Record; - const is404 = errorObj?.status === 404 || - (errorObj?.response as Record)?.status === 404 || + const is404 = errorObj?.status === 404 || + (errorObj?.response as Record)?.status === 404 || (errorObj?.data as Record)?.status_code === 404 || parsedObj?.status === 404 || (parsedObj?.data as Record)?.status_code === 404 || @@ -582,7 +416,7 @@ export class MeshProxyContract extends MeshTxInitiator { (errorObj?.message as string)?.includes('NOT_FOUND') || ((errorObj?.response as Record)?.data as Record)?.status_code === 404 || ((errorObj?.data as Record)?.status_code === 404); - + if (is404) { // DRep not registered yet - cache null result drepStatusCache.set(drepId, { @@ -591,7 +425,7 @@ export class MeshProxyContract extends MeshTxInitiator { }); return null; } - + // For other errors, don't cache and re-throw const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.log(`Failed to fetch DRep status: ${errorMessage}`); @@ -605,7 +439,7 @@ export class MeshProxyContract extends MeshTxInitiator { */ getDrepDelegators = async (forceRefresh = false) => { const drepId = this.getDrepId(); - + // First check if DRep is registered - don't fetch delegators if not registered const drepStatus = await this.getDrepStatus(forceRefresh); if (!drepStatus || drepStatus === null) { @@ -618,46 +452,46 @@ export class MeshProxyContract extends MeshTxInitiator { count: 0 }; } - + // Check cache first const cacheKey = `${drepId}_delegators`; const cached = drepStatusCache.get(cacheKey); if (!forceRefresh && cached && (Date.now() - cached.timestamp) < CACHE_DURATION) { return cached.data; } - + if (!this.mesh.fetcher) { throw new Error("Blockchain provider not found"); } - + try { const delegators = await this.mesh.fetcher.get( `/governance/dreps/${drepId}/delegators?count=100&page=1&order=asc`, ); - + // Calculate total delegation amount const totalDelegation = delegators.reduce((sum: bigint, delegator: { amount: string }) => { return sum + BigInt(delegator.amount); }, BigInt(0)); - + const result = { delegators, totalDelegation: totalDelegation.toString(), totalDelegationADA: Number(totalDelegation) / 1000000, // Convert to ADA count: delegators.length }; - + // Cache the successful result drepStatusCache.set(cacheKey, { data: result, timestamp: Date.now() }); - + return result; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; console.log(`Failed to fetch DRep delegators: ${errorMessage}`); - + // Return empty result for errors return { delegators: [], @@ -688,157 +522,33 @@ export class MeshProxyContract extends MeshTxInitiator { throw new Error("No votes provided"); } - // Get wallet info for transaction - const walletInfo = await this.getWalletInfoForTx(); - - // Use multisig inputs if provided, otherwise use regular wallet - const utxos = msUtxos ?? walletInfo.utxos; - const walletAddress = msWalletAddress ?? walletInfo.walletAddress; - - // Always get collateral from user's regular wallet - let collateral: UTxO; - try { - const collateralInfo = await this.getWalletInfoForTx(); - const foundCollateral = collateralInfo.utxos.find((utxo: UTxO) => - utxo.output.amount.some( - (amount: { unit: string; quantity: string }) => - amount.unit === "lovelace" && - BigInt(amount.quantity) >= BigInt(5000000), - ), - ); - if (!foundCollateral) { - throw new Error( - "No suitable collateral UTxO found in regular wallet. Please add at least 5 ADA to your regular wallet.", - ); - } - collateral = foundCollateral; - } catch { - throw new Error( - "Failed to get collateral from regular wallet. Please ensure you have at least 5 ADA in your regular wallet for transaction collateral.", - ); - } - - if (!walletAddress) { - throw new Error("No wallet address found"); - } - if (!collateral) { - throw new Error("No collateral found"); - } if (this.proxyAddress === undefined) { throw new Error("Proxy address not set. Please setupProxy first."); } - const blockchainProvider = this.mesh.fetcher; - if (!blockchainProvider) { - throw new Error("Blockchain provider not found"); - } - - const paramScriptAT = this.getAuthTokenCbor(); - const policyIdAT = resolveScriptHash(paramScriptAT, "V3"); - const authTokenUtxos = utxos.filter((utxo) => - utxo.output.amount.some((asset) => asset.unit === policyIdAT), - ); - - if (!authTokenUtxos || authTokenUtxos.length === 0) { - throw new Error("No AuthToken found at control wallet address"); - } - - 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"); - } - - const proxyCbor = this.getProxyCbor(); - const proxyScriptHash = resolveScriptHash(proxyCbor, "V3"); - const drepId = resolveScriptHashDRepId(proxyScriptHash); - - const txHex = this.mesh; - - // 1. Add AuthToken UTxO first (following manageProxyDrep pattern) - txHex.txIn( - authTokenUtxo.input.txHash, - authTokenUtxo.input.outputIndex, - authTokenUtxo.output.amount, - authTokenUtxo.output.address, - ); - - if (this.msCbor) { - txHex.txInScript(this.msCbor); - } + const { utxos, walletAddress, collateral } = + await this._resolveWalletInputs(msUtxos, msWalletAddress); - // 2. Add collateral - txHex.txInCollateral( - collateral.input.txHash, - collateral.input.outputIndex, - collateral.output.amount, - collateral.output.address, - ); - - // 3. Add additional UTxOs if needed (for voting fees) - const requiredAmount = BigInt(2000000); // 2 ADA for voting - let totalAmount = BigInt(0); - for (const utxo of utxos) { - if (totalAmount >= requiredAmount) { - break; - } - txHex.txIn( - utxo.input.txHash, - utxo.input.outputIndex, - utxo.output.amount, - utxo.output.address, - ); - if (this.msCbor) { - txHex.txInScript(this.msCbor); - } - totalAmount += BigInt( - (utxo.output.amount.find((asset: { unit: string; quantity: string }) => asset.unit === "lovelace") - ?.quantity) ?? "0", - ); - } - - // 4. Add output (return AuthToken) - txHex.txOut(walletAddress, [{ unit: policyIdAT, quantity: "1" }]); - - - // 5. Add votes for each proposal - for (const vote of votes) { - let txHash = ""; - let certIndex = 0; - try { - const parsed = parseProposalId(vote.proposalId); - txHash = parsed.txHash; - certIndex = parsed.certIndex; - } catch { - throw new Error(`Invalid proposal ID format: ${vote.proposalId}`); - } - - txHex - .votePlutusScriptV3() - .vote( - { - type: "DRep", - drepId: drepId, - }, - { - txHash: txHash, - txIndex: certIndex, - }, - { - voteKind: vote.voteKind, - }, - ) - .voteScript(this.getProxyCbor()) - .voteRedeemerValue("") - } + const scripts = deriveProxyScripts({ + paramUtxo: this.paramUtxo, + network: this.networkId, + stakeCredential: this.stakeCredential, + }); + const authTokenUtxo = selectAuthTokenUtxo(utxos, scripts.authTokenId); - // 6. Add certificate script and redeemer (following manageProxyDrep pattern) - txHex - .changeAddress(walletAddress); + buildProxyVoteTx({ + txBuilder: this.mesh, + network: this.networkId, + paramUtxo: this.paramUtxo, + walletUtxos: utxos, + authTokenUtxo, + collateral, + walletAddress, + votes, + multisigScriptCbor: this.msCbor, + stakeCredential: this.stakeCredential, + }); - return txHex; + return this.mesh; }; } diff --git a/src/components/pages/wallet/governance/drep/registerDrep.tsx b/src/components/pages/wallet/governance/drep/registerDrep.tsx index 521ff6c2..96d567d1 100644 --- a/src/components/pages/wallet/governance/drep/registerDrep.tsx +++ b/src/components/pages/wallet/governance/drep/registerDrep.tsx @@ -81,7 +81,7 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { async function createAnchor(): Promise<{ anchorUrl: string; - anchorHash: string; + anchorJson: object; }> { if (!appWallet) { const errorMessage = "Wallet not connected. Please ensure your wallet is connected and try again."; @@ -98,7 +98,7 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { formState, appWallet, ); - + // Upload the compacted JSON-LD (readable format) const rawResponse = await fetch("/api/pinata-storage/put", { method: "POST", @@ -113,11 +113,9 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { }); const res = (await rawResponse.json()) as PutResponse; const anchorUrl = res.url; - - // Compute hash from the canonicalized (normalized) form per CIP-100/CIP-119 - // The normalized form is in N-Quads format which is the canonical representation - const anchorHash = hashDrepAnchor(metadataResult.compacted); - return { anchorUrl, anchorHash }; + + // Return the raw JSON-LD object; the hash is computed deterministically inside the tx builder + return { anchorUrl, anchorJson: metadataResult.compacted }; } async function registerDrep(): Promise { @@ -172,7 +170,8 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { throw new Error("Script or change address not found"); } try { - const { anchorUrl, anchorHash } = await createAnchor(); + const { anchorUrl, anchorJson } = await createAnchor(); + const anchorDataHash = hashDrepAnchor(anchorJson); const selectedUtxos: UTxO[] = manualUtxos; @@ -194,7 +193,7 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { scriptCbor, changeAddress, utxos: selectedUtxos, - anchor: { anchorUrl, anchorDataHash: anchorHash }, + anchor: { anchorUrl, anchorDataHash }, }); await newTransaction({ @@ -260,7 +259,7 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { setLoading(true); try { - const { anchorUrl, anchorHash } = await createAnchor(); + const { anchorUrl, anchorJson } = await createAnchor(); // Get multisig inputs const { utxos, walletAddress } = await getMsInputs(); @@ -288,7 +287,7 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { proxyContract.proxyAddress = proxy.proxyAddress; // Register DRep using proxy - const txHex = await proxyContract.registerProxyDrep(anchorUrl, anchorHash, utxos, walletAddress); + const txHex = await proxyContract.registerProxyDrep(anchorUrl, anchorJson, utxos, walletAddress); await newTransaction({ txBuilder: txHex, diff --git a/src/components/pages/wallet/governance/drep/updateDrep.tsx b/src/components/pages/wallet/governance/drep/updateDrep.tsx index 0f60a518..ea92a968 100644 --- a/src/components/pages/wallet/governance/drep/updateDrep.tsx +++ b/src/components/pages/wallet/governance/drep/updateDrep.tsx @@ -78,7 +78,7 @@ export default function UpdateDRep({ onClose }: UpdateDRepProps = {}) { async function createAnchor(): Promise<{ anchorUrl: string; - anchorHash: string; + anchorJson: object; }> { if (!appWallet) { throw new Error("Wallet not connected"); @@ -89,7 +89,7 @@ export default function UpdateDRep({ onClose }: UpdateDRepProps = {}) { formState, appWallet, ); - + // Upload the compacted JSON-LD (readable format) const rawResponse = await fetch("/api/pinata-storage/put", { method: "POST", @@ -104,11 +104,9 @@ export default function UpdateDRep({ onClose }: UpdateDRepProps = {}) { }); const res = (await rawResponse.json()) as PutResponse; const anchorUrl = res.url; - - // Compute hash from the canonicalized (normalized) form per CIP-100/CIP-119 - // The normalized form is in N-Quads format which is the canonical representation - const anchorHash = hashDrepAnchor(metadataResult.compacted); - return { anchorUrl, anchorHash }; + + // Return the raw JSON-LD object; the hash is computed deterministically inside the tx builder + return { anchorUrl, anchorJson: metadataResult.compacted }; } async function updateProxyDrep(): Promise { @@ -136,7 +134,7 @@ export default function UpdateDRep({ onClose }: UpdateDRepProps = {}) { } // Create anchor metadata - const { anchorUrl, anchorHash } = await createAnchor(); + const { anchorUrl, anchorJson } = await createAnchor(); // Get multisig inputs const { utxos, walletAddress } = await getMsInputs(); @@ -157,7 +155,7 @@ export default function UpdateDRep({ onClose }: UpdateDRepProps = {}) { proxyContract.proxyAddress = proxy.proxyAddress; // Update DRep using proxy - const txHex = await proxyContract.updateProxyDrep(anchorUrl, anchorHash, utxos, walletAddress); + const txHex = await proxyContract.updateProxyDrep(anchorUrl, anchorJson, utxos, walletAddress); await newTransaction({ txBuilder: txHex, @@ -222,7 +220,8 @@ export default function UpdateDRep({ onClose }: UpdateDRepProps = {}) { throw new Error("Script or change address not found"); } try { - const { anchorUrl, anchorHash } = await createAnchor(); + const { anchorUrl, anchorJson } = await createAnchor(); + const anchorDataHash = hashDrepAnchor(anchorJson); const selectedUtxos: UTxO[] = manualUtxos; @@ -238,7 +237,7 @@ export default function UpdateDRep({ onClose }: UpdateDRepProps = {}) { scriptCbor, changeAddress, utxos: selectedUtxos, - anchor: { anchorUrl, anchorDataHash: anchorHash }, + anchor: { anchorUrl, anchorDataHash }, }); await newTransaction({ diff --git a/src/lib/proxy/txBuilders.ts b/src/lib/proxy/txBuilders.ts new file mode 100644 index 00000000..351b770d --- /dev/null +++ b/src/lib/proxy/txBuilders.ts @@ -0,0 +1,496 @@ +import { mConStr0, mConStr1, mOutputReference } from "@meshsdk/common"; +import { + applyParamsToScript, + hashDrepAnchor, + resolveScriptHash, + resolveScriptHashDRepId, + serializePlutusScript, +} from "@meshsdk/core"; +import type { MeshTxBuilder, UTxO } from "@meshsdk/core"; +import blueprint from "@/components/multisig/proxy/aiken-workspace/plutus.json"; +import { parseProposalId } from "@/lib/governance"; +import { getLovelace, sameUtxoRef } from "./utxoUtils"; + +export const DEFAULT_PROXY_SETUP_LOVELACE = "1000000"; +const PROXY_ACTION_MIN_LOVELACE = 2_000_000n; + +const DEFAULT_PROXY_STAKE_CREDENTIAL = + "c08f0294ead5ab7ae0ce5471dd487007919297ba95230af22f25e575"; + +export type ProxySetupInfo = { + paramUtxo: UTxO["input"]; + authTokenId: string; + proxyAddress: string; +}; + +export type ProxyVoteInput = { + proposalId: string; + voteKind: "Yes" | "No" | "Abstain"; + metadata?: unknown; +}; + +function formatAda(lovelace: bigint): string { + const whole = lovelace / 1_000_000n; + const fraction = lovelace % 1_000_000n; + if (fraction === 0n) return `${whole} ADA`; + return `${whole}.${fraction.toString().padStart(6, "0").replace(/0+$/, "")} ADA`; +} + +function assertSelectedLovelace(args: { + context: string; + selectedLovelace: bigint; + requiredLovelace: bigint; +}) { + if (args.selectedLovelace >= args.requiredLovelace) return; + throw new Error( + `${args.context} requires at least ${formatAda(args.requiredLovelace)} in selected wallet inputs, but only ${formatAda(args.selectedLovelace)} was selected`, + ); +} + +export function deriveProxyScripts(args: { + paramUtxo: UTxO["input"]; + network: number; + stakeCredential?: string; +}) { + const authTokenCbor = applyParamsToScript( + blueprint.validators[0]!.compiledCode, + [mOutputReference(args.paramUtxo.txHash, args.paramUtxo.outputIndex)], + ); + const authTokenId = resolveScriptHash(authTokenCbor, "V3"); + const proxyCbor = applyParamsToScript(blueprint.validators[2]!.compiledCode, [ + authTokenId, + ]); + const proxyAddress = serializePlutusScript( + { code: proxyCbor, version: "V3" }, + args.stakeCredential ?? DEFAULT_PROXY_STAKE_CREDENTIAL, + args.network, + ).address; + const proxyScriptHash = resolveScriptHash(proxyCbor, "V3"); + const dRepId = resolveScriptHashDRepId(proxyScriptHash); + + return { + authTokenCbor, + authTokenId, + proxyCbor, + proxyAddress, + dRepId, + }; +} + +function addScriptInput( + txBuilder: MeshTxBuilder, + utxo: UTxO, + scriptCbor?: string, +) { + txBuilder.txIn( + utxo.input.txHash, + utxo.input.outputIndex, + utxo.output.amount, + utxo.output.address, + ); + if (scriptCbor) { + txBuilder.txInScript(scriptCbor); + } +} + +function addCollateral(txBuilder: MeshTxBuilder, collateral: UTxO) { + txBuilder.txInCollateral( + collateral.input.txHash, + collateral.input.outputIndex, + collateral.output.amount, + collateral.output.address, + ); +} + +function selectParamUtxo(utxos: UTxO[]): UTxO | null { + return ( + utxos.find((utxo) => getLovelace(utxo) >= BigInt(20_000_000)) ?? null + ); +} + +export function buildProxySetupTx(args: { + txBuilder: MeshTxBuilder; + network: number; + walletUtxos: UTxO[]; + walletAddress: string; + collateral: UTxO; + multisigScriptCbor?: string; + initialProxyLovelace?: string; + stakeCredential?: string; +}): ProxySetupInfo { + const paramUtxo = selectParamUtxo(args.walletUtxos); + if (!paramUtxo) { + throw new Error( + "Insufficicient balance. Create one utxo holding at Least 20 ADA.", + ); + } + + const scripts = deriveProxyScripts({ + paramUtxo: paramUtxo.input, + network: args.network, + stakeCredential: args.stakeCredential, + }); + + addScriptInput(args.txBuilder, paramUtxo, args.multisigScriptCbor); + + args.txBuilder + .mintPlutusScriptV3() + .mint("10", scripts.authTokenId, "") + .mintingScript(scripts.authTokenCbor) + .mintRedeemerValue(mConStr0([])) + .txOut(scripts.proxyAddress, [ + { + unit: "lovelace", + quantity: args.initialProxyLovelace ?? DEFAULT_PROXY_SETUP_LOVELACE, + }, + ]); + + for (let i = 0; i < 10; i++) { + args.txBuilder.txOut(args.walletAddress, [ + { unit: scripts.authTokenId, quantity: "1" }, + ]); + } + + addCollateral(args.txBuilder, args.collateral); + args.txBuilder.changeAddress(args.walletAddress); + + return { + paramUtxo: paramUtxo.input, + authTokenId: scripts.authTokenId, + proxyAddress: scripts.proxyAddress, + }; +} + +export function buildProxySpendTx(args: { + txBuilder: MeshTxBuilder; + network: number; + proxyAddress: string; + paramUtxo: UTxO["input"]; + walletUtxos?: UTxO[]; + proxyUtxos: UTxO[]; + authTokenUtxo: UTxO; + collateral: UTxO; + outputs: { address: string; unit: string; amount: string }[]; + walletAddress: string; + multisigScriptCbor?: string; + stakeCredential?: string; +}) { + const scripts = deriveProxyScripts({ + paramUtxo: args.paramUtxo, + network: args.network, + stakeCredential: args.stakeCredential, + }); + + for (const proxyUtxo of args.proxyUtxos) { + args.txBuilder + .spendingPlutusScriptV3() + .txIn( + proxyUtxo.input.txHash, + proxyUtxo.input.outputIndex, + proxyUtxo.output.amount, + proxyUtxo.output.address, + ) + .txInScript(scripts.proxyCbor) + .txInInlineDatumPresent() + .txInRedeemerValue(mConStr0([])); + } + + addScriptInput(args.txBuilder, args.authTokenUtxo, args.multisigScriptCbor); + for (const utxo of (args.walletUtxos ?? [])) { + if (!sameUtxoRef(utxo.input, args.authTokenUtxo.input)) { + addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); + } + } + + addCollateral(args.txBuilder, args.collateral); + args.txBuilder.txOut(args.walletAddress, [ + { unit: scripts.authTokenId, quantity: "1" }, + ]); + + for (const output of args.outputs) { + args.txBuilder.txOut(output.address, [ + { unit: output.unit, quantity: output.amount }, + ]); + } + + args.txBuilder.changeAddress(args.proxyAddress); +} + +export function buildProxyDRepCertificateTx(args: { + txBuilder: MeshTxBuilder; + network: number; + paramUtxo: UTxO["input"]; + walletUtxos: UTxO[]; + authTokenUtxo: UTxO; + collateral: UTxO; + walletAddress: string; + action: "register" | "update" | "deregister"; + anchorUrl?: string; + anchorJson?: object; + multisigScriptCbor?: string; + stakeCredential?: string; +}): { dRepId: string; anchorDataHash?: string } { + const scripts = deriveProxyScripts({ + paramUtxo: args.paramUtxo, + network: args.network, + stakeCredential: args.stakeCredential, + }); + + let anchorDataHash: string | undefined; + if (args.action === "register" || args.action === "update") { + if (!args.anchorUrl || !args.anchorJson) { + throw new Error("anchorUrl and anchorJson are required for this action"); + } + anchorDataHash = hashDrepAnchor(args.anchorJson); + } + + addScriptInput(args.txBuilder, args.authTokenUtxo, args.multisigScriptCbor); + addCollateral(args.txBuilder, args.collateral); + + const requiredAmount = + args.action === "register" ? BigInt(505_000_000) : PROXY_ACTION_MIN_LOVELACE; + let totalAmount = getLovelace(args.authTokenUtxo); + for (const utxo of args.walletUtxos) { + if (totalAmount >= requiredAmount) { + break; + } + if (sameUtxoRef(utxo.input, args.authTokenUtxo.input)) { + continue; + } + addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); + totalAmount += getLovelace(utxo); + } + assertSelectedLovelace({ + context: `proxy DRep ${args.action}`, + selectedLovelace: totalAmount, + requiredLovelace: requiredAmount, + }); + + args.txBuilder.txOut(args.walletAddress, [ + { unit: scripts.authTokenId, quantity: "1" }, + ]); + + if (args.action === "register") { + args.txBuilder.drepRegistrationCertificate(scripts.dRepId, { + anchorUrl: args.anchorUrl!, + anchorDataHash: anchorDataHash!, + }); + } else if (args.action === "update") { + args.txBuilder.drepUpdateCertificate(scripts.dRepId, { + anchorUrl: args.anchorUrl!, + anchorDataHash: anchorDataHash!, + }); + } else { + args.txBuilder.drepDeregistrationCertificate(scripts.dRepId); + } + + args.txBuilder + .certificateScript(scripts.proxyCbor, "V3") + .certificateRedeemerValue(mConStr0([])) + .changeAddress(args.walletAddress); + + return { dRepId: scripts.dRepId, anchorDataHash }; +} + +export function buildProxyVoteTx(args: { + txBuilder: MeshTxBuilder; + network: number; + paramUtxo: UTxO["input"]; + walletUtxos: UTxO[]; + authTokenUtxo: UTxO; + collateral: UTxO; + walletAddress: string; + votes: ProxyVoteInput[]; + multisigScriptCbor?: string; + stakeCredential?: string; +}): { dRepId: string } { + if (args.votes.length === 0) { + throw new Error("votes must be a non-empty array"); + } + + const scripts = deriveProxyScripts({ + paramUtxo: args.paramUtxo, + network: args.network, + stakeCredential: args.stakeCredential, + }); + + addScriptInput(args.txBuilder, args.authTokenUtxo, args.multisigScriptCbor); + addCollateral(args.txBuilder, args.collateral); + + let totalAmount = getLovelace(args.authTokenUtxo); + for (const utxo of args.walletUtxos) { + if (totalAmount >= PROXY_ACTION_MIN_LOVELACE) { + break; + } + if (sameUtxoRef(utxo.input, args.authTokenUtxo.input)) { + continue; + } + addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); + totalAmount += getLovelace(utxo); + } + assertSelectedLovelace({ + context: "proxy vote", + selectedLovelace: totalAmount, + requiredLovelace: PROXY_ACTION_MIN_LOVELACE, + }); + + args.txBuilder.txOut(args.walletAddress, [ + { unit: scripts.authTokenId, quantity: "1" }, + ]); + + for (const vote of args.votes) { + const parsed = parseProposalId(vote.proposalId); + args.txBuilder + .votePlutusScriptV3() + .vote( + { + type: "DRep", + drepId: scripts.dRepId, + }, + { + txHash: parsed.txHash, + txIndex: parsed.certIndex, + }, + { + voteKind: vote.voteKind, + }, + ) + .voteScript(scripts.proxyCbor) + .voteRedeemerValue(""); + } + + args.txBuilder.changeAddress(args.walletAddress); + + return { dRepId: scripts.dRepId }; +} + +export function buildProxyCleanupTx(args: { + txBuilder: MeshTxBuilder; + network: number; + paramUtxo: UTxO["input"]; + walletUtxos: UTxO[]; + collateral: UTxO; + walletAddress: string; + authTokenId: string; + multisigScriptCbor?: string; + stakeCredential?: string; +}): { burnedAuthTokens: string } { + const scripts = deriveProxyScripts({ + paramUtxo: args.paramUtxo, + network: args.network, + stakeCredential: args.stakeCredential, + }); + if (scripts.authTokenId !== args.authTokenId) { + throw new Error("Stored proxy metadata does not match derived auth token"); + } + + let authTokenCount = BigInt(0); + for (const utxo of args.walletUtxos) { + const quantity = utxo.output.amount.find( + (asset) => asset.unit === args.authTokenId, + )?.quantity; + if (quantity) { + authTokenCount += BigInt(quantity); + } + addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); + } + + if (authTokenCount !== BigInt(10)) { + throw new Error( + `proxy cleanup requires exactly 10 auth tokens, found ${authTokenCount.toString()}`, + ); + } + + args.txBuilder + .mintPlutusScriptV3() + .mint("-10", scripts.authTokenId, "") + .mintingScript(scripts.authTokenCbor) + .mintRedeemerValue(mConStr1([])); + + addCollateral(args.txBuilder, args.collateral); + args.txBuilder.changeAddress(args.walletAddress); + + return { burnedAuthTokens: "10" }; +} + +function aggregateUtxoAmounts( + utxos: UTxO[], + extraAmounts: UTxO["output"]["amount"] = [], +): UTxO["output"]["amount"] { + const totals = new Map(); + for (const amounts of [ + ...utxos.map((utxo) => utxo.output.amount), + extraAmounts, + ]) { + for (const asset of amounts) { + totals.set(asset.unit, (totals.get(asset.unit) ?? BigInt(0)) + BigInt(asset.quantity)); + } + } + + return Array.from(totals.entries()).map(([unit, quantity]) => ({ + unit, + quantity: quantity.toString(), + })); +} + +export function buildProxyCleanupSweepTx(args: { + txBuilder: MeshTxBuilder; + network: number; + paramUtxo: UTxO["input"]; + proxyAddress: string; + proxyUtxos: UTxO[]; + walletUtxos: UTxO[]; + authTokenUtxo: UTxO; + collateral: UTxO; + walletAddress: string; + multisigScriptCbor?: string; + stakeCredential?: string; +}): { sweptProxyUtxos: string; preservedAuthTokens: string } { + if (args.proxyUtxos.length === 0) { + throw new Error("proxy cleanup sweep requires at least one proxy UTxO"); + } + + const scripts = deriveProxyScripts({ + paramUtxo: args.paramUtxo, + network: args.network, + stakeCredential: args.stakeCredential, + }); + + for (const proxyUtxo of args.proxyUtxos) { + if (proxyUtxo.output.address !== args.proxyAddress) { + throw new Error("proxy cleanup sweep received a UTxO outside the proxy address"); + } + args.txBuilder + .spendingPlutusScriptV3() + .txIn( + proxyUtxo.input.txHash, + proxyUtxo.input.outputIndex, + proxyUtxo.output.amount, + proxyUtxo.output.address, + ) + .txInScript(scripts.proxyCbor) + .txInInlineDatumPresent() + .txInRedeemerValue(mConStr0([])); + } + + addScriptInput(args.txBuilder, args.authTokenUtxo, args.multisigScriptCbor); + for (const utxo of args.walletUtxos) { + if (!sameUtxoRef(utxo.input, args.authTokenUtxo.input)) { + addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); + } + } + + addCollateral(args.txBuilder, args.collateral); + args.txBuilder.txOut( + args.walletAddress, + aggregateUtxoAmounts(args.proxyUtxos, [ + { unit: scripts.authTokenId, quantity: "1" }, + ]), + ); + args.txBuilder.changeAddress(args.walletAddress); + + return { + sweptProxyUtxos: args.proxyUtxos.length.toString(), + preservedAuthTokens: "1", + }; +} diff --git a/src/lib/proxy/utxoUtils.ts b/src/lib/proxy/utxoUtils.ts new file mode 100644 index 00000000..9d4d6b40 --- /dev/null +++ b/src/lib/proxy/utxoUtils.ts @@ -0,0 +1,123 @@ +import type { UTxO } from "@meshsdk/core"; + +export type UtxoRef = { txHash: string; outputIndex: number }; + +export function getLovelace(utxo: UTxO): bigint { + return BigInt( + utxo.output.amount.find((asset) => asset.unit === "lovelace")?.quantity ?? "0", + ); +} + +export function hasAsset(utxo: UTxO, unit: string, minimum = BigInt(1)): boolean { + const quantity = BigInt( + utxo.output.amount.find((asset) => asset.unit === unit)?.quantity ?? "0", + ); + return quantity >= minimum; +} + +export function sameUtxoRef(a: UTxO["input"], b: UTxO["input"]): boolean { + return a.txHash === b.txHash && a.outputIndex === b.outputIndex; +} + +/** + * Greedy UTxO selection covering `outputs` plus an optional `feeBuffer` of lovelace. + * Throws if the proxy balance is insufficient. + * Browser callers pass feeBuffer = 500_000n; server callers pass 0n or omit. + */ +export function selectProxyUtxosForOutputs( + proxyUtxos: UTxO[], + outputs: { unit: string; amount: string }[], + feeBuffer?: bigint, +): UTxO[] { + const requiredByUnit = new Map(); + for (const output of outputs) { + const amount = BigInt(output.amount); + requiredByUnit.set(output.unit, (requiredByUnit.get(output.unit) ?? 0n) + amount); + } + requiredByUnit.set( + "lovelace", + (requiredByUnit.get("lovelace") ?? 0n) + (feeBuffer ?? 0n), + ); + + const remainingByUnit = new Map(requiredByUnit); + const candidates = [...proxyUtxos]; + const selected: UTxO[] = []; + + const hasRemaining = () => Array.from(remainingByUnit.values()).some((v) => v > 0n); + + while (hasRemaining()) { + let bestIndex = -1; + let bestScore = 0n; + + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i]!; + let score = 0n; + for (const asset of candidate.output.amount) { + const remaining = remainingByUnit.get(asset.unit) ?? 0n; + if (remaining > 0n) { + const quantity = BigInt(asset.quantity); + score += quantity < remaining ? quantity : remaining; + } + } + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + + if (bestIndex === -1 || bestScore === 0n) { + throw new Error("Unable to select proxy UTxOs for requested outputs"); + } + + const chosen = candidates.splice(bestIndex, 1)[0]!; + selected.push(chosen); + for (const asset of chosen.output.amount) { + const remaining = remainingByUnit.get(asset.unit) ?? 0n; + if (remaining > 0n) { + const quantity = BigInt(asset.quantity); + remainingByUnit.set( + asset.unit, + remaining - (quantity < remaining ? quantity : remaining), + ); + } + } + } + + return selected; +} + +/** + * Picks the best free auth-token UTxO from wallet UTxOs. + * Skips any UTxOs whose refs appear in `blockedUtxoRefs` (server blocked-tx avoidance). + * Throws if no free auth-token UTxO is available. + */ +export function selectAuthTokenUtxo( + walletUtxos: UTxO[], + authTokenPolicyId: string, + blockedUtxoRefs?: UtxoRef[], +): UTxO { + const blocked = new Set( + (blockedUtxoRefs ?? []).map((ref) => `${ref.txHash}#${ref.outputIndex}`), + ); + + const candidates = walletUtxos.filter( + (utxo) => + !blocked.has(`${utxo.input.txHash}#${utxo.input.outputIndex}`) && + hasAsset(utxo, authTokenPolicyId), + ); + + if (candidates.length === 0) { + throw new Error( + "No AuthToken found at the multisig wallet address. Cancel or complete pending transactions that use the auth token, then try again.", + ); + } + + return candidates.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]!; +} diff --git a/src/lib/server/proxyTxBuilders.ts b/src/lib/server/proxyTxBuilders.ts index baab5413..0fb48e6f 100644 --- a/src/lib/server/proxyTxBuilders.ts +++ b/src/lib/server/proxyTxBuilders.ts @@ -1,494 +1 @@ -import { mConStr0, mConStr1, mOutputReference } from "@meshsdk/common"; -import { - applyParamsToScript, - hashDrepAnchor, - resolveScriptHash, - resolveScriptHashDRepId, - serializePlutusScript, -} from "@meshsdk/core"; -import type { MeshTxBuilder, UTxO } from "@meshsdk/core"; -import blueprint from "@/components/multisig/proxy/aiken-workspace/plutus.json"; -import { parseProposalId } from "@/lib/governance"; -import { getLovelace, sameUtxoRef } from "@/lib/server/proxyUtxos"; - -export const DEFAULT_PROXY_SETUP_LOVELACE = "1000000"; -const PROXY_ACTION_MIN_LOVELACE = 2_000_000n; - -const DEFAULT_PROXY_STAKE_CREDENTIAL = - "c08f0294ead5ab7ae0ce5471dd487007919297ba95230af22f25e575"; - -export type ProxySetupInfo = { - paramUtxo: UTxO["input"]; - authTokenId: string; - proxyAddress: string; -}; - -export type ProxyVoteInput = { - proposalId: string; - voteKind: "Yes" | "No" | "Abstain"; - metadata?: unknown; -}; - -function formatAda(lovelace: bigint): string { - const whole = lovelace / 1_000_000n; - const fraction = lovelace % 1_000_000n; - if (fraction === 0n) return `${whole} ADA`; - return `${whole}.${fraction.toString().padStart(6, "0").replace(/0+$/, "")} ADA`; -} - -function assertSelectedLovelace(args: { - context: string; - selectedLovelace: bigint; - requiredLovelace: bigint; -}) { - if (args.selectedLovelace >= args.requiredLovelace) return; - throw new Error( - `${args.context} requires at least ${formatAda(args.requiredLovelace)} in selected wallet inputs, but only ${formatAda(args.selectedLovelace)} was selected`, - ); -} - -export function deriveProxyScripts(args: { - paramUtxo: UTxO["input"]; - network: number; - stakeCredential?: string; -}) { - const authTokenCbor = applyParamsToScript( - blueprint.validators[0]!.compiledCode, - [mOutputReference(args.paramUtxo.txHash, args.paramUtxo.outputIndex)], - ); - const authTokenId = resolveScriptHash(authTokenCbor, "V3"); - const proxyCbor = applyParamsToScript(blueprint.validators[2]!.compiledCode, [ - authTokenId, - ]); - const proxyAddress = serializePlutusScript( - { code: proxyCbor, version: "V3" }, - args.stakeCredential ?? DEFAULT_PROXY_STAKE_CREDENTIAL, - args.network, - ).address; - const proxyScriptHash = resolveScriptHash(proxyCbor, "V3"); - const dRepId = resolveScriptHashDRepId(proxyScriptHash); - - return { - authTokenCbor, - authTokenId, - proxyCbor, - proxyAddress, - dRepId, - }; -} - -function addScriptInput( - txBuilder: MeshTxBuilder, - utxo: UTxO, - scriptCbor?: string, -) { - txBuilder.txIn( - utxo.input.txHash, - utxo.input.outputIndex, - utxo.output.amount, - utxo.output.address, - ); - if (scriptCbor) { - txBuilder.txInScript(scriptCbor); - } -} - -function addCollateral(txBuilder: MeshTxBuilder, collateral: UTxO) { - txBuilder.txInCollateral( - collateral.input.txHash, - collateral.input.outputIndex, - collateral.output.amount, - collateral.output.address, - ); -} - -function selectParamUtxo(utxos: UTxO[]): UTxO | null { - return ( - utxos.find((utxo) => getLovelace(utxo) >= BigInt(20_000_000)) ?? null - ); -} - -export function buildProxySetupTx(args: { - txBuilder: MeshTxBuilder; - network: number; - walletUtxos: UTxO[]; - walletAddress: string; - collateral: UTxO; - multisigScriptCbor?: string; - initialProxyLovelace?: string; - stakeCredential?: string; -}): ProxySetupInfo { - const paramUtxo = selectParamUtxo(args.walletUtxos); - if (!paramUtxo) { - throw new Error("No setup UTxO found with at least 20 ADA"); - } - - const scripts = deriveProxyScripts({ - paramUtxo: paramUtxo.input, - network: args.network, - stakeCredential: args.stakeCredential, - }); - - addScriptInput(args.txBuilder, paramUtxo, args.multisigScriptCbor); - - args.txBuilder - .mintPlutusScriptV3() - .mint("10", scripts.authTokenId, "") - .mintingScript(scripts.authTokenCbor) - .mintRedeemerValue(mConStr0([])) - .txOut(scripts.proxyAddress, [ - { - unit: "lovelace", - quantity: args.initialProxyLovelace ?? DEFAULT_PROXY_SETUP_LOVELACE, - }, - ]); - - for (let i = 0; i < 10; i++) { - args.txBuilder.txOut(args.walletAddress, [ - { unit: scripts.authTokenId, quantity: "1" }, - ]); - } - - addCollateral(args.txBuilder, args.collateral); - args.txBuilder.changeAddress(args.walletAddress); - - return { - paramUtxo: paramUtxo.input, - authTokenId: scripts.authTokenId, - proxyAddress: scripts.proxyAddress, - }; -} - -export function buildProxySpendTx(args: { - txBuilder: MeshTxBuilder; - network: number; - proxyAddress: string; - paramUtxo: UTxO["input"]; - walletUtxos: UTxO[]; - proxyUtxos: UTxO[]; - authTokenUtxo: UTxO; - collateral: UTxO; - outputs: { address: string; unit: string; amount: string }[]; - walletAddress: string; - multisigScriptCbor?: string; - stakeCredential?: string; -}) { - const scripts = deriveProxyScripts({ - paramUtxo: args.paramUtxo, - network: args.network, - stakeCredential: args.stakeCredential, - }); - - for (const proxyUtxo of args.proxyUtxos) { - args.txBuilder - .spendingPlutusScriptV3() - .txIn( - proxyUtxo.input.txHash, - proxyUtxo.input.outputIndex, - proxyUtxo.output.amount, - proxyUtxo.output.address, - ) - .txInScript(scripts.proxyCbor) - .txInInlineDatumPresent() - .txInRedeemerValue(mConStr0([])); - } - - addScriptInput(args.txBuilder, args.authTokenUtxo, args.multisigScriptCbor); - for (const utxo of args.walletUtxos) { - if (!sameUtxoRef(utxo.input, args.authTokenUtxo.input)) { - addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); - } - } - - addCollateral(args.txBuilder, args.collateral); - args.txBuilder.txOut(args.walletAddress, [ - { unit: scripts.authTokenId, quantity: "1" }, - ]); - - for (const output of args.outputs) { - args.txBuilder.txOut(output.address, [ - { unit: output.unit, quantity: output.amount }, - ]); - } - - args.txBuilder.changeAddress(args.proxyAddress); -} - -export function buildProxyDRepCertificateTx(args: { - txBuilder: MeshTxBuilder; - network: number; - paramUtxo: UTxO["input"]; - walletUtxos: UTxO[]; - authTokenUtxo: UTxO; - collateral: UTxO; - walletAddress: string; - action: "register" | "update" | "deregister"; - anchorUrl?: string; - anchorJson?: object; - multisigScriptCbor?: string; - stakeCredential?: string; -}): { dRepId: string; anchorDataHash?: string } { - const scripts = deriveProxyScripts({ - paramUtxo: args.paramUtxo, - network: args.network, - stakeCredential: args.stakeCredential, - }); - - let anchorDataHash: string | undefined; - if (args.action === "register" || args.action === "update") { - if (!args.anchorUrl || !args.anchorJson) { - throw new Error("anchorUrl and anchorJson are required for this action"); - } - anchorDataHash = hashDrepAnchor(args.anchorJson); - } - - addScriptInput(args.txBuilder, args.authTokenUtxo, args.multisigScriptCbor); - addCollateral(args.txBuilder, args.collateral); - - const requiredAmount = - args.action === "register" ? BigInt(505_000_000) : PROXY_ACTION_MIN_LOVELACE; - let totalAmount = getLovelace(args.authTokenUtxo); - for (const utxo of args.walletUtxos) { - if (totalAmount >= requiredAmount) { - break; - } - if (sameUtxoRef(utxo.input, args.authTokenUtxo.input)) { - continue; - } - addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); - totalAmount += getLovelace(utxo); - } - assertSelectedLovelace({ - context: `proxy DRep ${args.action}`, - selectedLovelace: totalAmount, - requiredLovelace: requiredAmount, - }); - - args.txBuilder.txOut(args.walletAddress, [ - { unit: scripts.authTokenId, quantity: "1" }, - ]); - - if (args.action === "register") { - args.txBuilder.drepRegistrationCertificate(scripts.dRepId, { - anchorUrl: args.anchorUrl!, - anchorDataHash: anchorDataHash!, - }); - } else if (args.action === "update") { - args.txBuilder.drepUpdateCertificate(scripts.dRepId, { - anchorUrl: args.anchorUrl!, - anchorDataHash: anchorDataHash!, - }); - } else { - args.txBuilder.drepDeregistrationCertificate(scripts.dRepId); - } - - args.txBuilder - .certificateScript(scripts.proxyCbor, "V3") - .certificateRedeemerValue(mConStr0([])) - .changeAddress(args.walletAddress); - - return { dRepId: scripts.dRepId, anchorDataHash }; -} - -export function buildProxyVoteTx(args: { - txBuilder: MeshTxBuilder; - network: number; - paramUtxo: UTxO["input"]; - walletUtxos: UTxO[]; - authTokenUtxo: UTxO; - collateral: UTxO; - walletAddress: string; - votes: ProxyVoteInput[]; - multisigScriptCbor?: string; - stakeCredential?: string; -}): { dRepId: string } { - if (args.votes.length === 0) { - throw new Error("votes must be a non-empty array"); - } - - const scripts = deriveProxyScripts({ - paramUtxo: args.paramUtxo, - network: args.network, - stakeCredential: args.stakeCredential, - }); - - addScriptInput(args.txBuilder, args.authTokenUtxo, args.multisigScriptCbor); - addCollateral(args.txBuilder, args.collateral); - - let totalAmount = getLovelace(args.authTokenUtxo); - for (const utxo of args.walletUtxos) { - if (totalAmount >= PROXY_ACTION_MIN_LOVELACE) { - break; - } - if (sameUtxoRef(utxo.input, args.authTokenUtxo.input)) { - continue; - } - addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); - totalAmount += getLovelace(utxo); - } - assertSelectedLovelace({ - context: "proxy vote", - selectedLovelace: totalAmount, - requiredLovelace: PROXY_ACTION_MIN_LOVELACE, - }); - - args.txBuilder.txOut(args.walletAddress, [ - { unit: scripts.authTokenId, quantity: "1" }, - ]); - - for (const vote of args.votes) { - const parsed = parseProposalId(vote.proposalId); - args.txBuilder - .votePlutusScriptV3() - .vote( - { - type: "DRep", - drepId: scripts.dRepId, - }, - { - txHash: parsed.txHash, - txIndex: parsed.certIndex, - }, - { - voteKind: vote.voteKind, - }, - ) - .voteScript(scripts.proxyCbor) - .voteRedeemerValue(""); - } - - args.txBuilder.changeAddress(args.walletAddress); - - return { dRepId: scripts.dRepId }; -} - -export function buildProxyCleanupTx(args: { - txBuilder: MeshTxBuilder; - network: number; - paramUtxo: UTxO["input"]; - walletUtxos: UTxO[]; - collateral: UTxO; - walletAddress: string; - authTokenId: string; - multisigScriptCbor?: string; - stakeCredential?: string; -}): { burnedAuthTokens: string } { - const scripts = deriveProxyScripts({ - paramUtxo: args.paramUtxo, - network: args.network, - stakeCredential: args.stakeCredential, - }); - if (scripts.authTokenId !== args.authTokenId) { - throw new Error("Stored proxy metadata does not match derived auth token"); - } - - let authTokenCount = BigInt(0); - for (const utxo of args.walletUtxos) { - const quantity = utxo.output.amount.find( - (asset) => asset.unit === args.authTokenId, - )?.quantity; - if (quantity) { - authTokenCount += BigInt(quantity); - } - addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); - } - - if (authTokenCount !== BigInt(10)) { - throw new Error( - `proxy cleanup requires exactly 10 auth tokens, found ${authTokenCount.toString()}`, - ); - } - - args.txBuilder - .mintPlutusScriptV3() - .mint("-10", scripts.authTokenId, "") - .mintingScript(scripts.authTokenCbor) - .mintRedeemerValue(mConStr1([])); - - addCollateral(args.txBuilder, args.collateral); - args.txBuilder.changeAddress(args.walletAddress); - - return { burnedAuthTokens: "10" }; -} - -function aggregateUtxoAmounts( - utxos: UTxO[], - extraAmounts: UTxO["output"]["amount"] = [], -): UTxO["output"]["amount"] { - const totals = new Map(); - for (const amounts of [ - ...utxos.map((utxo) => utxo.output.amount), - extraAmounts, - ]) { - for (const asset of amounts) { - totals.set(asset.unit, (totals.get(asset.unit) ?? BigInt(0)) + BigInt(asset.quantity)); - } - } - - return Array.from(totals.entries()).map(([unit, quantity]) => ({ - unit, - quantity: quantity.toString(), - })); -} - -export function buildProxyCleanupSweepTx(args: { - txBuilder: MeshTxBuilder; - network: number; - paramUtxo: UTxO["input"]; - proxyAddress: string; - proxyUtxos: UTxO[]; - walletUtxos: UTxO[]; - authTokenUtxo: UTxO; - collateral: UTxO; - walletAddress: string; - multisigScriptCbor?: string; - stakeCredential?: string; -}): { sweptProxyUtxos: string; preservedAuthTokens: string } { - if (args.proxyUtxos.length === 0) { - throw new Error("proxy cleanup sweep requires at least one proxy UTxO"); - } - - const scripts = deriveProxyScripts({ - paramUtxo: args.paramUtxo, - network: args.network, - stakeCredential: args.stakeCredential, - }); - - for (const proxyUtxo of args.proxyUtxos) { - if (proxyUtxo.output.address !== args.proxyAddress) { - throw new Error("proxy cleanup sweep received a UTxO outside the proxy address"); - } - args.txBuilder - .spendingPlutusScriptV3() - .txIn( - proxyUtxo.input.txHash, - proxyUtxo.input.outputIndex, - proxyUtxo.output.amount, - proxyUtxo.output.address, - ) - .txInScript(scripts.proxyCbor) - .txInInlineDatumPresent() - .txInRedeemerValue(mConStr0([])); - } - - addScriptInput(args.txBuilder, args.authTokenUtxo, args.multisigScriptCbor); - for (const utxo of args.walletUtxos) { - if (!sameUtxoRef(utxo.input, args.authTokenUtxo.input)) { - addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); - } - } - - addCollateral(args.txBuilder, args.collateral); - args.txBuilder.txOut( - args.walletAddress, - aggregateUtxoAmounts(args.proxyUtxos, [ - { unit: scripts.authTokenId, quantity: "1" }, - ]), - ); - args.txBuilder.changeAddress(args.walletAddress); - - return { - sweptProxyUtxos: args.proxyUtxos.length.toString(), - preservedAuthTokens: "1", - }; -} +export * from "@/lib/proxy/txBuilders"; diff --git a/src/lib/server/proxyUtxos.ts b/src/lib/server/proxyUtxos.ts index cadb6364..9158c46b 100644 --- a/src/lib/server/proxyUtxos.ts +++ b/src/lib/server/proxyUtxos.ts @@ -1,7 +1,10 @@ +import type { PrismaClient } from "@prisma/client"; import type { UTxO } from "@meshsdk/core"; -import type { UtxoFetcher, UtxoRef } from "@/lib/server/resolveUtxoRefsFromChain"; +import type { UtxoFetcher } from "@/lib/server/resolveUtxoRefsFromChain"; +import { type UtxoRef, getLovelace, hasAsset, sameUtxoRef } from "@/lib/proxy/utxoUtils"; export type { UtxoRef }; +export { getLovelace, hasAsset, sameUtxoRef }; const MIN_COLLATERAL_LOVELACE = BigInt(5_000_000); @@ -19,24 +22,6 @@ function normalizeUtxoRef(ref: UtxoRef | undefined): UtxoRef | null { return { txHash, outputIndex }; } -export function getLovelace(utxo: UTxO): bigint { - return BigInt( - utxo.output.amount.find((asset) => asset.unit === "lovelace")?.quantity ?? - "0", - ); -} - -export function hasAsset(utxo: UTxO, unit: string, minimum = BigInt(1)): boolean { - const quantity = BigInt( - utxo.output.amount.find((asset) => asset.unit === unit)?.quantity ?? "0", - ); - return quantity >= minimum; -} - -export function sameUtxoRef(a: UTxO["input"], b: UTxO["input"]): boolean { - return a.txHash === b.txHash && a.outputIndex === b.outputIndex; -} - export async function resolveSingleUtxoRefFromChain(args: { network: number; ref: UtxoRef | undefined; @@ -199,6 +184,21 @@ export function requireAuthTokenUtxo( return authTokenUtxo; } +/** + * Loads all UTxO refs that are consumed by currently-pending multisig transactions for a wallet. + * API routes pass these to `selectAuthTokenUtxo` so a locked auth token is never reused. + */ +export async function loadBlockedUtxoRefsForWallet( + db: PrismaClient, + walletId: string, +): Promise { + const pendingTxs = await db.transaction.findMany({ + where: { walletId, state: 0 }, + select: { txJson: true }, + }); + return pendingTxs.flatMap((tx) => extractBlockedUtxoRefsFromPendingTxJson(tx.txJson)); +} + export function selectProxyUtxosForOutputs(args: { proxyUtxos: UTxO[]; outputs: { unit: string; amount: string; address?: string }[]; diff --git a/src/pages/api/v1/proxyCleanup.ts b/src/pages/api/v1/proxyCleanup.ts index 12b23b1f..393d5db6 100644 --- a/src/pages/api/v1/proxyCleanup.ts +++ b/src/pages/api/v1/proxyCleanup.ts @@ -13,11 +13,12 @@ import { loadActiveProxyForWallet } from "@/lib/server/proxyAccess"; import { resolveWalletScriptAddress } from "@/lib/server/walletScriptAddress"; import { resolveUtxoRefsFromChain } from "@/lib/server/resolveUtxoRefsFromChain"; import { - requireAuthTokenUtxo, + loadBlockedUtxoRefsForWallet, resolveCollateralRefFromChain, resolveSingleUtxoRefFromChain, type UtxoRef, } from "@/lib/server/proxyUtxos"; +import { selectAuthTokenUtxo } from "@/lib/proxy/utxoUtils"; import { createPendingMultisigTransaction } from "@/lib/server/createPendingMultisigTransaction"; import { completeTxWithFreshCostModels } from "@/lib/server/completeTxWithFreshCostModels"; import { getProvider } from "@/utils/get-provider"; @@ -227,16 +228,16 @@ export default async function handler( return res.status(proxyUtxosResult.status).json({ error: proxyUtxosResult.error }); } + const blockedRefs = await loadBlockedUtxoRefsForWallet(db, walletId); const txBuilder = getTxBuilder(network, true) as MeshTxBuilderWithBody; let cleanup: CleanupMetadata; try { if (proxyUtxosResult.utxos.length > 0) { - const authTokenUtxo = requireAuthTokenUtxo( - resolvedWalletUtxos.utxos, - proxy.authTokenId, - ); - if ("error" in authTokenUtxo) { - return res.status(authTokenUtxo.status).json({ error: authTokenUtxo.error }); + let authTokenUtxo: UTxO; + try { + authTokenUtxo = selectAuthTokenUtxo(resolvedWalletUtxos.utxos, proxy.authTokenId, blockedRefs); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "No free auth token UTxO" }); } cleanup = { phase: "sweep", diff --git a/src/pages/api/v1/proxyDRepCertificate.ts b/src/pages/api/v1/proxyDRepCertificate.ts index da132550..74a57645 100644 --- a/src/pages/api/v1/proxyDRepCertificate.ts +++ b/src/pages/api/v1/proxyDRepCertificate.ts @@ -1,4 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import type { UTxO } from "@meshsdk/core"; import { db } from "@/server/db"; import { verifyJwt, isBotJwt } from "@/lib/verifyJwt"; import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; @@ -12,10 +13,11 @@ import { loadActiveProxyForWallet } from "@/lib/server/proxyAccess"; import { resolveWalletScriptAddress } from "@/lib/server/walletScriptAddress"; import { resolveUtxoRefsFromChain } from "@/lib/server/resolveUtxoRefsFromChain"; import { - requireAuthTokenUtxo, + loadBlockedUtxoRefsForWallet, resolveCollateralRefFromChain, type UtxoRef, } from "@/lib/server/proxyUtxos"; +import { selectAuthTokenUtxo } from "@/lib/proxy/utxoUtils"; import { createPendingMultisigTransaction } from "@/lib/server/createPendingMultisigTransaction"; import { completeTxWithFreshCostModels } from "@/lib/server/completeTxWithFreshCostModels"; import { getTxBuilder } from "@/utils/get-tx-builder"; @@ -171,12 +173,12 @@ export default async function handler( return res.status(resolvedWalletUtxos.status).json({ error: resolvedWalletUtxos.error }); } - const authTokenUtxo = requireAuthTokenUtxo( - resolvedWalletUtxos.utxos, - proxy.authTokenId, - ); - if ("error" in authTokenUtxo) { - return res.status(authTokenUtxo.status).json({ error: authTokenUtxo.error }); + const blockedRefs = await loadBlockedUtxoRefsForWallet(db, walletId); + let authTokenUtxo: UTxO; + try { + authTokenUtxo = selectAuthTokenUtxo(resolvedWalletUtxos.utxos, proxy.authTokenId, blockedRefs); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "No free auth token UTxO" }); } const resolvedCollateral = await resolveCollateralRefFromChain({ diff --git a/src/pages/api/v1/proxySpend.ts b/src/pages/api/v1/proxySpend.ts index 6a291851..b5462e28 100644 --- a/src/pages/api/v1/proxySpend.ts +++ b/src/pages/api/v1/proxySpend.ts @@ -13,12 +13,13 @@ import { loadActiveProxyForWallet } from "@/lib/server/proxyAccess"; import { resolveWalletScriptAddress } from "@/lib/server/walletScriptAddress"; import { resolveUtxoRefsFromChain } from "@/lib/server/resolveUtxoRefsFromChain"; import { - requireAuthTokenUtxo, + loadBlockedUtxoRefsForWallet, resolveCollateralRefFromChain, resolveSingleUtxoRefFromChain, selectProxyUtxosForOutputs, type UtxoRef, } from "@/lib/server/proxyUtxos"; +import { selectAuthTokenUtxo } from "@/lib/proxy/utxoUtils"; import { createPendingMultisigTransaction } from "@/lib/server/createPendingMultisigTransaction"; import { completeTxWithFreshCostModels } from "@/lib/server/completeTxWithFreshCostModels"; import { getProvider } from "@/utils/get-provider"; @@ -219,12 +220,12 @@ export default async function handler( return res.status(resolvedWalletUtxos.status).json({ error: resolvedWalletUtxos.error }); } - const authTokenUtxo = requireAuthTokenUtxo( - resolvedWalletUtxos.utxos, - proxy.authTokenId, - ); - if ("error" in authTokenUtxo) { - return res.status(authTokenUtxo.status).json({ error: authTokenUtxo.error }); + const blockedRefs = await loadBlockedUtxoRefsForWallet(db, walletId); + let authTokenUtxo: UTxO; + try { + authTokenUtxo = selectAuthTokenUtxo(resolvedWalletUtxos.utxos, proxy.authTokenId, blockedRefs); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "No free auth token UTxO" }); } const resolvedCollateral = await resolveCollateralRefFromChain({ diff --git a/src/pages/api/v1/proxyVote.ts b/src/pages/api/v1/proxyVote.ts index 5945b429..a63c2185 100644 --- a/src/pages/api/v1/proxyVote.ts +++ b/src/pages/api/v1/proxyVote.ts @@ -1,4 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; +import type { UTxO } from "@meshsdk/core"; import { db } from "@/server/db"; import { verifyJwt, isBotJwt } from "@/lib/verifyJwt"; import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; @@ -12,10 +13,11 @@ import { loadActiveProxyForWallet } from "@/lib/server/proxyAccess"; import { resolveWalletScriptAddress } from "@/lib/server/walletScriptAddress"; import { resolveUtxoRefsFromChain } from "@/lib/server/resolveUtxoRefsFromChain"; import { - requireAuthTokenUtxo, + loadBlockedUtxoRefsForWallet, resolveCollateralRefFromChain, type UtxoRef, } from "@/lib/server/proxyUtxos"; +import { selectAuthTokenUtxo } from "@/lib/proxy/utxoUtils"; import { createPendingMultisigTransaction } from "@/lib/server/createPendingMultisigTransaction"; import { completeTxWithFreshCostModels } from "@/lib/server/completeTxWithFreshCostModels"; import { getTxBuilder } from "@/utils/get-tx-builder"; @@ -194,12 +196,12 @@ export default async function handler( return res.status(resolvedWalletUtxos.status).json({ error: resolvedWalletUtxos.error }); } - const authTokenUtxo = requireAuthTokenUtxo( - resolvedWalletUtxos.utxos, - proxy.authTokenId, - ); - if ("error" in authTokenUtxo) { - return res.status(authTokenUtxo.status).json({ error: authTokenUtxo.error }); + const blockedRefs = await loadBlockedUtxoRefsForWallet(db, walletId); + let authTokenUtxo: UTxO; + try { + authTokenUtxo = selectAuthTokenUtxo(resolvedWalletUtxos.utxos, proxy.authTokenId, blockedRefs); + } catch (error) { + return res.status(400).json({ error: error instanceof Error ? error.message : "No free auth token UTxO" }); } const resolvedCollateral = await resolveCollateralRefFromChain({