diff --git a/.cursor/skills/multisig/SKILL.md b/.cursor/skills/multisig/SKILL.md index 2c00e8c4..e6220781 100644 --- a/.cursor/skills/multisig/SKILL.md +++ b/.cursor/skills/multisig/SKILL.md @@ -38,6 +38,7 @@ description: Build and integrate with the Mesh Multisig (Cardano multisig wallet - **Bot keys**: Created in-app (User → Create bot). One bot key can have one `paymentAddress`; same address cannot be used by another bot. - **Scopes**: Bot keys have scope (e.g. `multisig:read`); `botAccess.ts` enforces wallet access for bots. - **V1 endpoints used by bots**: `walletIds` (query `address` = bot’s `paymentAddress`), `pendingTransactions`, `freeUtxos`, `addTransaction`, `signTransaction`, etc. Same as wallet-authenticated calls but identity is the bot’s registered address. +- **⚠️ Proxy spends (`proxySpend`) — AuthToken UTxOs**: `freeUtxos` marks each UTxO with `authToken: true/false`. A proxy-enabled wallet holds up to 10 AuthToken UTxOs. When calling `POST /api/v1/proxySpend`, your `utxoRefs` must include **exactly one** `authToken: true` UTxO (the one the API will use to authorize the spend) and **zero** others. Including a second `authToken: true` UTxO will be rejected server-side (`buildProxySpendTx` throws), but do not rely on that — filter them out (`authToken !== true`, keeping only the single one you intend) yourself during coin selection. ## Conventions diff --git a/src/__tests__/freeUtxos.bot.test.ts b/src/__tests__/freeUtxos.bot.test.ts index ff4de4dd..e3f50b05 100644 --- a/src/__tests__/freeUtxos.bot.test.ts +++ b/src/__tests__/freeUtxos.bot.test.ts @@ -11,6 +11,7 @@ const isBotJwtMock: jest.Mock = jest.fn(); const getBotWalletAccessMock: jest.Mock = jest.fn(); const assertBotWalletAccessMock: jest.Mock = jest.fn(); const findPendingTransactionsMock: jest.Mock = jest.fn(); +const findProxiesMock: jest.Mock = jest.fn(); const buildMultisigWalletMock: jest.Mock = jest.fn(); const addressToNetworkMock: jest.Mock = jest.fn(); const getProviderMock: jest.Mock = jest.fn(); @@ -49,6 +50,7 @@ jest.mock("@/server/db", () => ({ __esModule: true, db: { transaction: { findMany: findPendingTransactionsMock }, + proxy: { findMany: findProxiesMock }, }, })); @@ -111,6 +113,7 @@ beforeEach(() => { isBotJwtMock.mockReturnValue(true); (getBotWalletAccessMock as any).mockResolvedValue({ allowed: true, role: "cosigner" }); (findPendingTransactionsMock as any).mockResolvedValue([]); + (findProxiesMock as any).mockResolvedValue([]); (assertBotWalletAccessMock as any).mockResolvedValue({ wallet: { id: "wallet-1" }, role: "cosigner" }); buildMultisigWalletMock.mockReturnValue({ getScript: () => ({ address: "addr_test1walletscript" }), @@ -149,7 +152,7 @@ describe("freeUtxos bot API", () => { await handler(req, res); expect(cachedFetchAddressUTxOsMock).toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith([{ input: { txHash: "a", outputIndex: 0 } }]); + expect(res.json).toHaveBeenCalledWith([{ input: { txHash: "a", outputIndex: 0 }, authToken: false }]); }); it("falls back to direct provider fetch when cached UTxO lookup fails", async () => { @@ -165,7 +168,7 @@ describe("freeUtxos bot API", () => { expect(fetchAddressUTxOsMock).toHaveBeenCalledWith("addr_test1walletscript"); expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith([{ input: { txHash: "direct", outputIndex: 1 } }]); + expect(res.json).toHaveBeenCalledWith([{ input: { txHash: "direct", outputIndex: 1 }, authToken: false }]); }); it("returns an empty array when the provider has no UTxOs for the script address", async () => { diff --git a/src/__tests__/proxyAuthTokenLeakGuard.test.ts b/src/__tests__/proxyAuthTokenLeakGuard.test.ts new file mode 100644 index 00000000..031529f6 --- /dev/null +++ b/src/__tests__/proxyAuthTokenLeakGuard.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "@jest/globals"; +import type { UTxO } from "@meshsdk/core"; +import { + assertNoStrayAuthTokenUtxos, + hasAsset, +} from "@/lib/proxy/utxoUtils"; +import { buildProxySpendTx, deriveProxyScripts } from "@/lib/proxy/txBuilders"; + +const WALLET_ADDRESS = "addr_test1qpwalletfixtureaddress0000000000000000000000000000"; +const PROXY_PARAM_UTXO = { txHash: "a".repeat(64), outputIndex: 0 }; + +function mkUtxo( + address: string, + amount: UTxO["output"]["amount"], + txHash: string, + outputIndex = 0, +): UTxO { + return { input: { txHash, outputIndex }, output: { address, amount } }; +} + +function createNoopTxBuilder() { + const builder: any = new Proxy( + {}, + { + get(_t, prop) { + if (prop === "then") return undefined; + return () => builder; + }, + }, + ); + return builder; +} + +describe("assertNoStrayAuthTokenUtxos (proxy AuthToken leak guard)", () => { + const AUTH_TOKEN_ID = "d".repeat(56) + "6d79546f6b656e"; + const designated = mkUtxo( + WALLET_ADDRESS, + [{ unit: "lovelace", quantity: "2000000" }, { unit: AUTH_TOKEN_ID, quantity: "1" }], + "b".repeat(64), + ); + + it("passes when walletUtxos contains only plain ADA UTxOs alongside the designated AuthToken", () => { + const plainAda = mkUtxo(WALLET_ADDRESS, [{ unit: "lovelace", quantity: "5000000" }], "c".repeat(64)); + expect(() => + assertNoStrayAuthTokenUtxos([designated, plainAda], AUTH_TOKEN_ID, designated), + ).not.toThrow(); + }); + + it("throws when walletUtxos contains a second, non-designated AuthToken UTxO", () => { + const strayAuthToken = mkUtxo( + WALLET_ADDRESS, + [{ unit: "lovelace", quantity: "2000000" }, { unit: AUTH_TOKEN_ID, quantity: "1" }], + "e".repeat(64), + ); + expect(() => + assertNoStrayAuthTokenUtxos([designated, strayAuthToken], AUTH_TOKEN_ID, designated), + ).toThrow(/additional AuthToken UTxO/i); + }); + + it("hasAsset correctly detects the AuthToken unit", () => { + expect(hasAsset(designated, AUTH_TOKEN_ID)).toBe(true); + expect(hasAsset(designated, "lovelace", 3_000_000n)).toBe(false); + }); +}); + +describe("buildProxySpendTx stray AuthToken protection (regression for proxyAddress drain bug)", () => { + const scripts = deriveProxyScripts({ paramUtxo: PROXY_PARAM_UTXO, network: 0 }); + + const authTokenUtxo = mkUtxo( + WALLET_ADDRESS, + [{ unit: "lovelace", quantity: "2000000" }, { unit: scripts.authTokenId, quantity: "1" }], + "b".repeat(64), + ); + const collateral = mkUtxo(WALLET_ADDRESS, [{ unit: "lovelace", quantity: "5000000" }], "f".repeat(64)); + + it("rejects the transaction when an extra AuthToken UTxO is included as wallet funding", () => { + const strayAuthTokenUtxo = mkUtxo( + WALLET_ADDRESS, + [{ unit: "lovelace", quantity: "2000000" }, { unit: scripts.authTokenId, quantity: "1" }], + "e".repeat(64), + ); + + expect(() => + buildProxySpendTx({ + txBuilder: createNoopTxBuilder(), + network: 0, + proxyAddress: scripts.proxyAddress, + paramUtxo: PROXY_PARAM_UTXO, + walletUtxos: [strayAuthTokenUtxo], + proxyUtxos: [], + authTokenUtxo, + collateral, + outputs: [{ address: WALLET_ADDRESS, unit: "lovelace", amount: "1000000" }], + walletAddress: WALLET_ADDRESS, + }), + ).toThrow(/additional AuthToken UTxO/i); + }); + + it("still builds normally when walletUtxos only contains plain ADA funding", () => { + const plainAda = mkUtxo(WALLET_ADDRESS, [{ unit: "lovelace", quantity: "5000000" }], "c".repeat(64)); + + expect(() => + buildProxySpendTx({ + txBuilder: createNoopTxBuilder(), + network: 0, + proxyAddress: scripts.proxyAddress, + paramUtxo: PROXY_PARAM_UTXO, + walletUtxos: [plainAda], + proxyUtxos: [], + authTokenUtxo, + collateral, + outputs: [{ address: WALLET_ADDRESS, unit: "lovelace", amount: "1000000" }], + walletAddress: WALLET_ADDRESS, + }), + ).not.toThrow(); + }); +}); diff --git a/src/lib/proxy/txBuilders.ts b/src/lib/proxy/txBuilders.ts index 02265e50..8ac59f1c 100644 --- a/src/lib/proxy/txBuilders.ts +++ b/src/lib/proxy/txBuilders.ts @@ -9,7 +9,13 @@ import { import type { MeshTxBuilder, UTxO } from "@meshsdk/core"; import blueprint from "@/components/multisig/proxy/aiken-workspace/plutus.json"; import { parseProposalId } from "@/lib/governance"; -import { accumulateFundingUtxos, getLovelace, sameUtxoRef, selectSetupUtxo } from "./utxoUtils"; +import { + accumulateFundingUtxos, + assertNoStrayAuthTokenUtxos, + getLovelace, + sameUtxoRef, + selectSetupUtxo, +} from "./utxoUtils"; export const DEFAULT_PROXY_SETUP_LOVELACE = "1000000"; const PROXY_ACTION_MIN_LOVELACE = 2_000_000n; @@ -175,6 +181,12 @@ export function buildProxySpendTx(args: { stakeCredential: args.stakeCredential, }); + assertNoStrayAuthTokenUtxos( + args.walletUtxos ?? [], + scripts.authTokenId, + args.authTokenUtxo, + ); + for (const proxyUtxo of args.proxyUtxos) { args.txBuilder .spendingPlutusScriptV3() @@ -436,6 +448,12 @@ export function buildProxyCleanupSweepTx(args: { stakeCredential: args.stakeCredential, }); + assertNoStrayAuthTokenUtxos( + args.walletUtxos, + scripts.authTokenId, + args.authTokenUtxo, + ); + 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"); diff --git a/src/lib/proxy/utxoUtils.ts b/src/lib/proxy/utxoUtils.ts index 487a8683..92f94729 100644 --- a/src/lib/proxy/utxoUtils.ts +++ b/src/lib/proxy/utxoUtils.ts @@ -19,6 +19,31 @@ export function sameUtxoRef(a: UTxO["input"], b: UTxO["input"]): boolean { return a.txHash === b.txHash && a.outputIndex === b.outputIndex; } +/** + * Security guard: proxy spend/sweep transactions must only ever spend a single, + * explicitly-designated AuthToken UTxO. A wallet holds up to 10 identical AuthToken + * UTxOs (minted at proxy setup); if a caller-supplied `walletUtxos` list includes any + * of the *other* AuthToken UTxOs alongside the designated one, they would silently be + * spent as plain funding inputs and their AuthToken asset would leak out as change. + * For `buildProxySpendTx`, that change lands at `proxyAddress`, whose validator has no + * signer/quorum check - the leaked token would let anyone drain the proxy address. + * Fail closed instead of letting that happen. + */ +export function assertNoStrayAuthTokenUtxos( + walletUtxos: UTxO[], + authTokenPolicyId: string, + designatedAuthTokenUtxo: UTxO, +): void { + for (const utxo of walletUtxos) { + if (sameUtxoRef(utxo.input, designatedAuthTokenUtxo.input)) continue; + if (hasAsset(utxo, authTokenPolicyId)) { + throw new Error( + `walletUtxos contains an additional AuthToken UTxO (${utxo.input.txHash}#${utxo.input.outputIndex}) besides the designated authTokenUtxo. Refusing to build this transaction: spending it here would leak the AuthToken as unprotected change. Exclude every AuthToken-bearing UTxO from walletUtxos except the one you intend to use.`, + ); + } + } +} + /** * Greedy UTxO selection covering `outputs` plus an optional `feeBuffer` of lovelace. * Throws if the proxy balance is insufficient. diff --git a/src/pages/api/v1/freeUtxos.ts b/src/pages/api/v1/freeUtxos.ts index 3586c6b2..40695fce 100644 --- a/src/pages/api/v1/freeUtxos.ts +++ b/src/pages/api/v1/freeUtxos.ts @@ -184,11 +184,24 @@ export default async function handler( ), ); + // Flag UTxOs that carry an active proxy AuthToken so callers building a + // proxySpend request have a positive signal to avoid sweeping in more than + // one of them as generic funding (see buildProxySpendTx's stray-AuthToken guard). + const activeProxies = await db.proxy.findMany({ + where: { walletId, isActive: true }, + select: { authTokenId: true }, + }); + const authTokenIds = new Set(activeProxies.map((p) => p.authTokenId)); + const freeUtxosWithAuthTokenFlag = freeUtxos.map((utxo) => ({ + ...utxo, + authToken: (utxo.output?.amount ?? []).some((asset) => authTokenIds.has(asset.unit)), + })); + res.setHeader( "Cache-Control", fresh ? "no-store" : "public, s-maxage=30, stale-while-revalidate=60", ); - res.status(200).json(freeUtxos); + res.status(200).json(freeUtxosWithAuthTokenFlag); } catch (error) { console.error("Error in freeUtxos handler", { message: (error as Error)?.message,