Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cursor/skills/multisig/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions src/__tests__/freeUtxos.bot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -49,6 +50,7 @@ jest.mock("@/server/db", () => ({
__esModule: true,
db: {
transaction: { findMany: findPendingTransactionsMock },
proxy: { findMany: findProxiesMock },
},
}));

Expand Down Expand Up @@ -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" }),
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
117 changes: 117 additions & 0 deletions src/__tests__/proxyAuthTokenLeakGuard.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
20 changes: 19 additions & 1 deletion src/lib/proxy/txBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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");
Expand Down
25 changes: 25 additions & 0 deletions src/lib/proxy/utxoUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 14 additions & 1 deletion src/pages/api/v1/freeUtxos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down