Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .cursor/skills/multisig/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ 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 auth tokens (bots and builders)**: a proxy-enabled wallet holds up to 10 auth-token UTxOs (one token each; policy = `Proxy.authTokenId`). The on-chain proxy validator only checks that *some* output carries the token and has no signer check, so the safety invariant lives off-chain: **every auth token spent must return to the multisig wallet address**. The builders in `src/lib/proxy/txBuilders.ts` enforce this by construction (`returnAuthTokensToWallet`): spend/DRep/vote return tokens split 1 per output to keep the pool of independently spendable tokens, while the cleanup sweep returns them all in the swept-value output ahead of the burn, so the token UTxO keeps real ADA for the burn's fee (a token-only output would get bare min-ADA; splitting 1-per-output in the sweep starves the sweep itself). Cleanup callers should pass every auth-token UTxO plus an ADA-only wallet UTxO. Every v1 proxy handler audits the finished CBOR (`src/lib/server/proxyAuthTokenAudit.ts`), and every path that persists or witnesses a pending tx (`v1/signTransaction`, tRPC `transaction.createTransaction` / `importTransaction` / `updateTransaction`) refuses a tx that would move a token elsewhere. The audit covers **every `Proxy` row of the wallet regardless of `isActive`** (members can flip that flag; `proxy.deleteProxy` is a soft delete that only sets `isActive: false`) and additionally refuses any tx that **burns** the auth-token policy while the proxy address still holds UTxOs (a burn is irreversible; anything left at the proxy would be locked forever). The browser runs the same audit through tRPC `transaction.preSignProxyAudit` before `wallet.signTx` and fails closed if it cannot be reached (`src/utils/preSignProxyAudit.ts`). `freeUtxos` marks each UTxO with `authToken: boolean` and `authTokenId: string | null` for every proxy row (active or not); bots should still pass only the single token UTxO they intend to use in `utxoRefs` for `proxySpend`/`proxyDRepCertificate`/`proxyVote` (cleanup needs all of them). `buildProxySpendTx` also returns any token found among the proxy inputs to the wallet, so a token that leaked to the proxy address can be recovered with a spend. Never route a token to any address other than the wallet script address.
- **Proxy address stake credential**: new proxies use the proxy script's own hash as a *script* stake credential (`ProxyStakeCredential = { kind: "self" }`, the default of `deriveProxyScripts`), so withdrawals and delegation of the proxy's rewards are governed by the same validator as spends and need an auth-token output like any other proxy action. There is no hardcoded key hash anymore. Anything that works with an existing proxy must derive its stake credential from the stored address with `proxyStakeCredentialFromAddress(proxy.proxyAddress)` and pass `stake` to `deriveProxyScripts` / the builders (the v1 handlers, `MeshProxyContract`'s `proxyAddress` constructor option, and the CI recovery scripts already do); re-deriving with the default would point at a different address for proxies created before this change.

## Conventions

Expand Down
2 changes: 2 additions & 0 deletions scripts/bot-ref/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ All proxy transaction builders accept UTxO references, not raw UTxO JSON:

Use `GET /api/v1/freeUtxos?walletId=...&address=...&fresh=true` to select wallet inputs. `collateralRef` must be an ADA-only UTxO with at least 5 ADA at the bot payment address. Proxy actions also require a wallet input containing the proxy auth token, returned from setup/finalization metadata as `authTokenId`.

**Auth-token hygiene.** A proxy-enabled wallet holds up to 10 auth-token UTxOs. `freeUtxos` marks them with `authToken: true` and `authTokenId`. For `proxySpend`, `proxyDRepCertificate`, and `proxyVote`, include exactly one `authToken: true` UTxO in `utxoRefs` and select the rest of your funding from `authToken: false` UTxOs. The server returns every auth token it spends to the wallet address (split 1 per output for these actions) and audits the finished transaction, so a stray token cannot leak to the proxy address; `signTransaction` also refuses (409) any pending transaction for a proxy wallet that would move a token elsewhere. Cleanup is the exception: pass all auth-token UTxOs (the burn phase needs all 10) plus at least one `authToken: false` UTxO with a few ADA, because token UTxOs may hold only min-ADA and the burn has nothing else to pay the fee and change from. The sweep returns the tokens in a single output together with the swept proxy value.

### Setup and finalize

1. `POST /api/v1/proxySetup` with `walletId`, `address`, `utxoRefs`, `collateralRef`, optional `initialProxyLovelace`, and optional `description`.
Expand Down
4 changes: 2 additions & 2 deletions scripts/ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,9 @@ The first full-lifecycle steps for each eligible wallet type are ordered as:

Step 6, `v1.proxy.client-build-smoke.<walletType>`, is a non-submitting step that calls `buildProxySetupTx` from `src/lib/proxy/txBuilders.ts` directly with a real `MeshTxBuilder` and Blockfrost-resolved UTxOs. It fetches UTxOs at the wallet script address and the bot payment address from Blockfrost, selects a param UTxO (≥ 20 ADA) and the branch's reserved ADA-only collateral, builds the setup transaction, and calls `txBuilder.complete()` to run the Aiken evaluator against preprod. It asserts that the result is a non-empty CBOR hex string and discards it without calling `addTransaction` or any signing step. This catches blueprint schema regressions (`plutus.json` validator index changes), `applyParamsToScript`/`resolveScriptHash` failures, Aiken evaluator errors, and `MeshTxBuilder` API changes that would not be caught by the mock-builder unit tests in `src/__tests__/proxyTxBuilders.test.ts`. Marked non-critical so a slow Blockfrost evaluator response does not block the lifecycle steps that follow.

Chain recovery is CI-only and evidence-based. It scans non-lovelace assets at the current bootstrap `walletAddress` (up to 25 asset candidates; excess are skipped), asks Blockfrost for each asset's mint transaction, tests the mint transaction inputs as candidate `paramUtxo` values with `deriveProxyScripts`, and only creates or reactivates a `Proxy` row when the derived `authTokenId` exactly matches the observed asset unit. This handles clean-database rebuilds where old proxy auth tokens and proxy DReps remain on-chain but the app has no `Proxy` rows. It cannot recover a proxy if the auth token is no longer discoverable at the current CI wallet address.
Chain recovery is CI-only and evidence-based. It scans non-lovelace assets at the current bootstrap `walletAddress` (up to 25 asset candidates; excess are skipped), asks Blockfrost for each asset's mint transaction, tests the mint transaction inputs as candidate `paramUtxo` values with `deriveProxyScripts`, and only creates or reactivates a `Proxy` row when the derived `authTokenId` exactly matches the observed asset unit. The stored `proxyAddress` is taken from the mint transaction's own outputs (the one paying the proxy script hash) rather than re-derived with today's default stake credential, so proxies created before the proxy address switched to a self (script) stake credential are recovered at the address they actually use; orphan adoption likewise derives the stake credential from the adopted row's `proxyAddress`. This handles clean-database rebuilds where old proxy auth tokens and proxy DReps remain on-chain but the app has no `Proxy` rows. It cannot recover a proxy if the auth token is no longer discoverable at the current CI wallet address.

When preflight passes, each eligible wallet lifecycle creates its own proxy, finalizes the confirmed setup, exercises proxy spend, proxy DRep register/deregister, optional proxy voting when active governance proposals exist, then runs safe cleanup and asserts the proxy no longer appears in `GET /api/v1/proxies`. Proxy actions always use the branch's reserved bot payment-address collateral, which is distinct from both selected wallet spend inputs and other parallel branches' collateral. DRep registration selects an auth-token input plus additional wallet inputs when needed to meet the registration budget. The proposer/collateral owner is signer index 0 (`CI_MNEMONIC_1`), and signer index 1 (`CI_MNEMONIC_2`) broadcasts for the default threshold-2 proxy actions. After each broadcasted proxy action, the route-chain waits for the selected wallet inputs to disappear from fresh `freeUtxos` before proposing the next action. Cleanup may require two submitted transactions: a sweep transaction that empties the proxy address while preserving an auth token, followed by a burn transaction and cleanup finalization. If the initial cleanup call already returns a burn transaction, the optional burn proposal is skipped after that transaction is signed. Because this scenario runs on every PR, the default CI legacy, hierarchical, and SDK wallets must stay funded; one-UTxO shape problems are repaired by the self-split step, while true budget failures still fail the route-chain rather than skipping proxy lifecycle coverage.
When preflight passes, each eligible wallet lifecycle creates its own proxy, finalizes the confirmed setup, exercises proxy spend, proxy DRep register/deregister, optional proxy voting when active governance proposals exist, then runs safe cleanup and asserts the proxy no longer appears in `GET /api/v1/proxies`. Proxy actions always use the branch's reserved bot payment-address collateral, which is distinct from both selected wallet spend inputs and other parallel branches' collateral. DRep registration selects an auth-token input plus additional wallet inputs when needed to meet the registration budget. The proposer/collateral owner is signer index 0 (`CI_MNEMONIC_1`), and signer index 1 (`CI_MNEMONIC_2`) broadcasts for the default threshold-2 proxy actions. After each broadcasted proxy action, the route-chain waits for the selected wallet inputs to disappear from fresh `freeUtxos` before proposing the next action. Cleanup may require two submitted transactions: a sweep transaction that empties the proxy address while returning every auth token to the wallet, followed by a burn transaction and cleanup finalization. The server-side auth-token audit refuses to persist or witness the burn if the proxy address still holds UTxOs (for example a deposit that landed between sweep and burn), so a failing burn step means the proxy needs another sweep first, not that the burn builder is broken. Cleanup selects every auth-token UTxO plus plain ADA wallet inputs up to a 4 ADA budget, because token UTxOs can hold only min-ADA and the burn has no other inputs to pay its fee and change from. If the initial cleanup call already returns a burn transaction, the optional burn proposal is skipped after that transaction is signed. Because this scenario runs on every PR, the default CI legacy, hierarchical, and SDK wallets must stay funded; one-UTxO shape problems are repaired by the self-split step, while true budget failures still fail the route-chain rather than skipping proxy lifecycle coverage.

Runtime expectation: `scenario.proxy-smoke` is the quick, non-mutating proxy subset. `scenario.proxy-full-lifecycle` is a real-chain scenario with multiple broadcasts per eligible wallet and can dominate default PR smoke duration during slow preprod/Blockfrost periods. The expensive per-wallet lifecycles run in parallel by default after the isolation step, but the GitHub Actions job timeout is still intentionally higher than the nominal happy path to leave room for confirmation polling.

Expand Down
24 changes: 22 additions & 2 deletions scripts/ci/scenarios/proxyChainRecovery.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { PrismaClient, type Proxy as DbProxy } from "@prisma/client";
import { createPrismaClient } from "../framework/prismaClient";
import { BlockfrostProvider, type UTxO } from "@meshsdk/core";
import { deriveProxyScripts } from "../../../src/lib/server/proxyTxBuilders";
import {
deriveProxyScripts,
findProxyAddressInOutputs,
proxyStakeCredentialFromAddress,
} from "../../../src/lib/server/proxyTxBuilders";
import type { UtxoRef } from "../../../src/lib/server/proxyUtxos";
import type { CIBootstrapContext, CIWalletType } from "../framework/types";
import { getWalletByType } from "./steps/helpers";
Expand Down Expand Up @@ -58,10 +62,12 @@ type AssetHistoryEntry = {
type TxUtxoEntry = {
tx_hash?: string;
output_index?: number;
address?: string;
};

type TxUtxosResponse = {
inputs?: TxUtxoEntry[];
outputs?: TxUtxoEntry[];
};

export type ProxyChainRecoverySkipReason =
Expand Down Expand Up @@ -231,13 +237,27 @@ async function inspectAssetCandidate(args: {
}

const inputs = Array.isArray(txUtxos.inputs) ? txUtxos.inputs : [];
const outputAddresses = (Array.isArray(txUtxos.outputs) ? txUtxos.outputs : [])
.map((output) => (typeof output.address === "string" ? output.address : ""))
.filter(Boolean);
for (const input of inputs) {
const paramUtxo = inputToRef(input);
if (!paramUtxo) continue;

const scripts = deriveProxyScripts({ paramUtxo, network: args.network });
let scripts = deriveProxyScripts({ paramUtxo, network: args.network });
if (scripts.authTokenId !== args.assetUnit) continue;

// The setup tx paid the proxy at whatever stake-credential derivation was
// current at the time; adopt that address rather than today's default.
const paidAddress = findProxyAddressInOutputs(outputAddresses, scripts.proxyScriptHash);
if (paidAddress && paidAddress !== scripts.proxyAddress) {
scripts = deriveProxyScripts({
paramUtxo,
network: args.network,
stake: proxyStakeCredentialFromAddress(paidAddress),
});
}

let proxyUtxoCount: number | undefined;
try {
proxyUtxoCount = (await args.provider.fetchAddressUTxOs(scripts.proxyAddress)).length;
Expand Down
11 changes: 9 additions & 2 deletions scripts/ci/scenarios/proxyOrphanAdoption.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { PrismaClient, type Proxy as DbProxy, type Wallet as DbWallet } from "@prisma/client";
import { createPrismaClient } from "../framework/prismaClient";
import { BlockfrostProvider, type UTxO } from "@meshsdk/core";
import { deriveProxyScripts } from "../../../src/lib/server/proxyTxBuilders";
import {
deriveProxyScripts,
proxyStakeCredentialFromAddress,
} from "../../../src/lib/server/proxyTxBuilders";
import { hasAsset, type UtxoRef } from "../../../src/lib/server/proxyUtxos";
import { resolveWalletScriptAddressSafe } from "../../../src/lib/server/walletScriptAddress";
import type { CIBootstrapContext, CIWalletType } from "../framework/types";
Expand Down Expand Up @@ -242,7 +245,11 @@ export async function adoptProxyOrphansForWalletType(args: {
continue;
}

const scripts = deriveProxyScripts({ paramUtxo, network: args.ctx.networkId });
const scripts = deriveProxyScripts({
paramUtxo,
network: args.ctx.networkId,
stake: proxyStakeCredentialFromAddress(proxy.proxyAddress),
});
if (scripts.authTokenId !== proxy.authTokenId || scripts.proxyAddress !== proxy.proxyAddress) {
skipped.push({
proxyId: proxy.id,
Expand Down
34 changes: 29 additions & 5 deletions scripts/ci/scenarios/steps/proxyBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ const PROXY_LIFECYCLE_COLLATERAL_SIGNER_INDEX = 0;
const PROXY_LIFECYCLE_SIGNER_INDEXES = [0, 1] as const;
export const PROXY_ACTION_REQUIRED_LOVELACE = 2_000_000n;
export const PROXY_ACTION_FEE_BUFFER_LOVELACE = 2_000_000n;
/**
* Minimum lovelace across the wallet inputs passed to `proxyCleanup` (sweep or burn):
* fee plus a min-ADA change output, with headroom for Plutus script costs.
*/
export const PROXY_CLEANUP_REQUIRED_LOVELACE = 4_000_000n;

export function getProxyDRepAnchorUrl(
env: Record<string, string | undefined> = process.env,
Expand Down Expand Up @@ -248,14 +253,33 @@ export function selectAuthTokenRefs(args: {
}): { utxoRefs: UtxoRef[]; collateralRef: UtxoRef } {
const collateral = selectSeparateCollateral(args.collateralUtxos, "proxy action", args.reservedCollateralRef);
if (args.includeAllAuthTokens) {
// Cleanup path: include every auth-token UTxO
const authTokenUtxos = args.walletUtxos.filter((utxo) =>
utxo.output.amount.some((asset) => asset.unit === args.authTokenId && BigInt(asset.quantity) > 0n),
);
// Cleanup path: include every auth-token UTxO, plus plain ADA funding when the
// token UTxOs alone cannot cover the fee and a min-ADA change output. Token UTxOs
// may carry only min-ADA (e.g. the bundled output of a previous sweep, or 1-token
// outputs from spend/DRep/vote), and the burn phase has no other inputs.
const isAuthToken = (utxo: ScriptUtxo) =>
utxo.output.amount.some((asset) => asset.unit === args.authTokenId && BigInt(asset.quantity) > 0n);
const authTokenUtxos = args.walletUtxos.filter(isAuthToken);
if (!authTokenUtxos.length) {
throw new Error("No proxy auth-token UTxO found in freeUtxos response");
}
return { utxoRefs: authTokenUtxos.map(toRef), collateralRef: toRef(collateral) };
const requiredLovelace = PROXY_CLEANUP_REQUIRED_LOVELACE;
const selected = [...authTokenUtxos];
let selectedLovelace = selected.reduce((sum, utxo) => sum + parseLovelace(utxo), 0n);
const funding = args.walletUtxos
.filter((utxo) => !isAuthToken(utxo))
.sort((left, right) => Number(parseLovelace(right) - parseLovelace(left)));
for (const utxo of funding) {
if (selectedLovelace >= requiredLovelace) break;
selected.push(utxo);
selectedLovelace += parseLovelace(utxo);
}
if (selectedLovelace < requiredLovelace) {
throw new Error(
`proxy cleanup requires ${formatAda(requiredLovelace)} in selected wallet inputs but only ${formatAda(selectedLovelace)} is available after reserving separate collateral. Fund or consolidate the CI wallet before running scenario.proxy-full-lifecycle.`,
);
}
return { utxoRefs: selected.map(toRef), collateralRef: toRef(collateral) };
}
// Standard path: use the same selection as the client (highest lovelace, blocked-ref filtering)
const authTokenUtxo = selectAuthTokenUtxo(args.walletUtxos as unknown as UTxO[], args.authTokenId);
Expand Down
Loading
Loading