diff --git a/.cursor/skills/multisig/SKILL.md b/.cursor/skills/multisig/SKILL.md index 2c00e8c4..7892da1c 100644 --- a/.cursor/skills/multisig/SKILL.md +++ b/.cursor/skills/multisig/SKILL.md @@ -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 diff --git a/scripts/bot-ref/README.md b/scripts/bot-ref/README.md index c0955c89..5ad5446b 100644 --- a/scripts/bot-ref/README.md +++ b/scripts/bot-ref/README.md @@ -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`. diff --git a/scripts/ci/README.md b/scripts/ci/README.md index 855b5a39..cf2f2c2b 100644 --- a/scripts/ci/README.md +++ b/scripts/ci/README.md @@ -150,9 +150,9 @@ The first full-lifecycle steps for each eligible wallet type are ordered as: Step 6, `v1.proxy.client-build-smoke.`, 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. diff --git a/scripts/ci/scenarios/proxyChainRecovery.ts b/scripts/ci/scenarios/proxyChainRecovery.ts index 18a7bb0b..7e71d600 100644 --- a/scripts/ci/scenarios/proxyChainRecovery.ts +++ b/scripts/ci/scenarios/proxyChainRecovery.ts @@ -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"; @@ -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 = @@ -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; diff --git a/scripts/ci/scenarios/proxyOrphanAdoption.ts b/scripts/ci/scenarios/proxyOrphanAdoption.ts index 7e4fb745..11158ce1 100644 --- a/scripts/ci/scenarios/proxyOrphanAdoption.ts +++ b/scripts/ci/scenarios/proxyOrphanAdoption.ts @@ -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"; @@ -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, diff --git a/scripts/ci/scenarios/steps/proxyBot.ts b/scripts/ci/scenarios/steps/proxyBot.ts index 3712a0ad..c8d0a5cf 100644 --- a/scripts/ci/scenarios/steps/proxyBot.ts +++ b/scripts/ci/scenarios/steps/proxyBot.ts @@ -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 = process.env, @@ -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); diff --git a/src/__tests__/authTokenAudit.test.ts b/src/__tests__/authTokenAudit.test.ts new file mode 100644 index 00000000..b08a70de --- /dev/null +++ b/src/__tests__/authTokenAudit.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { csl } from "@meshsdk/core-csl"; +import { + auditAuthTokenOutputs, + findAuthTokenBurns, + formatAuthTokenAuditError, +} from "@/lib/proxy/authTokenAudit"; +import { auditAuthTokenBurn, auditBuiltProxyTx } from "@/lib/server/proxyAuthTokenAudit"; + +const POLICY = "d".repeat(56); +const OTHER_POLICY = "e".repeat(56); + +function enterpriseAddress(keyHashHex: string): string { + return csl.EnterpriseAddress.new( + csl.NetworkInfo.testnet_preview().network_id(), + csl.Credential.from_keyhash(csl.Ed25519KeyHash.from_hex(keyHashHex)), + ) + .to_address() + .to_bech32(); +} + +const WALLET = enterpriseAddress("1".repeat(56)); +const PROXY = enterpriseAddress("2".repeat(56)); +const STRANGER = enterpriseAddress("3".repeat(56)); + +type OutputSpec = { + address: string; + lovelace?: string; + tokens?: { policy: string; quantity: string; name?: string }[]; +}; + +type MintSpec = { policy: string; quantity: string; name?: string }; + +function buildTxHex(specs: OutputSpec[], mints: MintSpec[] = []): string { + const inputs = csl.TransactionInputs.new(); + inputs.add( + csl.TransactionInput.new(csl.TransactionHash.from_bytes(Buffer.from("00".repeat(32), "hex")), 0), + ); + + const outputs = csl.TransactionOutputs.new(); + for (const spec of specs) { + const value = csl.Value.new(csl.BigNum.from_str(spec.lovelace ?? "2000000")); + if (spec.tokens && spec.tokens.length > 0) { + const multiasset = csl.MultiAsset.new(); + for (const token of spec.tokens) { + const assets = csl.Assets.new(); + assets.insert( + csl.AssetName.new(Buffer.from(token.name ?? "", "hex")), + csl.BigNum.from_str(token.quantity), + ); + multiasset.insert(csl.ScriptHash.from_hex(token.policy), assets); + } + value.set_multiasset(multiasset); + } + outputs.add(csl.TransactionOutput.new(csl.Address.from_bech32(spec.address), value)); + } + + const body = csl.TransactionBody.new(inputs, outputs, csl.BigNum.from_str("200000"), undefined); + if (mints.length > 0) { + const mint = csl.Mint.new(); + for (const spec of mints) { + const assets = csl.MintAssets.new(); + assets.insert( + csl.AssetName.new(Buffer.from(spec.name ?? "", "hex")), + csl.Int.from_str(spec.quantity), + ); + mint.insert(csl.ScriptHash.from_hex(spec.policy), assets); + } + body.set_mint(mint); + } + return csl.Transaction.new(body, csl.TransactionWitnessSet.new(), undefined).to_hex(); +} + +describe("auditAuthTokenOutputs", () => { + it("passes when every auth token returns to the wallet as a 1-token output", () => { + const txCbor = buildTxHex([ + { address: WALLET, tokens: [{ policy: POLICY, quantity: "1" }] }, + { address: WALLET, tokens: [{ policy: POLICY, quantity: "1" }] }, + { address: PROXY, lovelace: "9000000" }, + { address: STRANGER, lovelace: "1000000" }, + ]); + + expect(auditAuthTokenOutputs({ txCbor, authTokenIds: [POLICY], walletAddress: WALLET })).toEqual({ + ok: true, + tokenOutputs: 2, + }); + }); + + it("passes a burn (no token outputs at all)", () => { + const txCbor = buildTxHex([{ address: WALLET, lovelace: "5000000" }]); + expect(auditAuthTokenOutputs({ txCbor, authTokenIds: [POLICY], walletAddress: WALLET })).toEqual({ + ok: true, + tokenOutputs: 0, + }); + }); + + it("fails when a token lands at the proxy address (the drain scenario)", () => { + const txCbor = buildTxHex([ + { address: WALLET, tokens: [{ policy: POLICY, quantity: "1" }] }, + { address: PROXY, lovelace: "9000000", tokens: [{ policy: POLICY, quantity: "1" }] }, + ]); + + const result = auditAuthTokenOutputs({ txCbor, authTokenIds: [POLICY], walletAddress: WALLET }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.violations).toHaveLength(1); + expect(result.violations[0]).toMatch(/output #1 .* address other than the multisig wallet/); + } + }); + + it("fails when a token goes to any third-party address", () => { + const txCbor = buildTxHex([{ address: STRANGER, tokens: [{ policy: POLICY, quantity: "1" }] }]); + const result = auditAuthTokenOutputs({ txCbor, authTokenIds: [POLICY], walletAddress: WALLET }); + expect(result.ok).toBe(false); + }); + + it("allows tokens bundled into one output at the wallet (cleanup sweep, ordinary wallet change)", () => { + const txCbor = buildTxHex([{ address: WALLET, tokens: [{ policy: POLICY, quantity: "10" }] }]); + expect(auditAuthTokenOutputs({ txCbor, authTokenIds: [POLICY], walletAddress: WALLET })).toEqual({ + ok: true, + tokenOutputs: 1, + }); + }); + + it("still fails when a bundled token output is anywhere but the wallet", () => { + const txCbor = buildTxHex([{ address: PROXY, tokens: [{ policy: POLICY, quantity: "10" }] }]); + const result = auditAuthTokenOutputs({ txCbor, authTokenIds: [POLICY], walletAddress: WALLET }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.violations[0]).toMatch(/sends 10 auth token\(s\)/); + }); + + it("ignores unrelated policies and checks all listed proxies", () => { + const txCbor = buildTxHex([ + { address: STRANGER, tokens: [{ policy: OTHER_POLICY, quantity: "7" }] }, + { address: WALLET, tokens: [{ policy: POLICY, quantity: "1" }] }, + ]); + + expect( + auditAuthTokenOutputs({ txCbor, authTokenIds: [POLICY], walletAddress: WALLET }).ok, + ).toBe(true); + expect( + auditAuthTokenOutputs({ txCbor, authTokenIds: [POLICY, OTHER_POLICY], walletAddress: WALLET }).ok, + ).toBe(false); + }); + + it("is a no-op when the wallet has no auth token policies", () => { + const txCbor = buildTxHex([{ address: STRANGER, tokens: [{ policy: POLICY, quantity: "1" }] }]); + expect(auditAuthTokenOutputs({ txCbor, authTokenIds: [], walletAddress: WALLET })).toEqual({ + ok: true, + tokenOutputs: 0, + }); + }); + + it("fails closed on undecodable CBOR and on an unparsable wallet address", () => { + const bad = auditAuthTokenOutputs({ txCbor: "not-cbor", authTokenIds: [POLICY], walletAddress: WALLET }); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.violations[0]).toMatch(/could not be decoded/); + + const txCbor = buildTxHex([{ address: WALLET, tokens: [{ policy: POLICY, quantity: "1" }] }]); + const badAddr = auditAuthTokenOutputs({ txCbor, authTokenIds: [POLICY], walletAddress: "nope" }); + expect(badAddr.ok).toBe(false); + }); + + it("accepts the wallet address in hex as well as bech32", () => { + const txCbor = buildTxHex([{ address: WALLET, tokens: [{ policy: POLICY, quantity: "1" }] }]); + const walletHex = csl.Address.from_bech32(WALLET).to_hex(); + expect( + auditAuthTokenOutputs({ txCbor, authTokenIds: [POLICY], walletAddress: walletHex }).ok, + ).toBe(true); + }); +}); + +describe("findAuthTokenBurns", () => { + it("reports the policies with a negative mint quantity", () => { + const txCbor = buildTxHex( + [{ address: WALLET, lovelace: "5000000" }], + [ + { policy: POLICY, quantity: "-10" }, + { policy: OTHER_POLICY, quantity: "3" }, + ], + ); + expect(findAuthTokenBurns({ txCbor, authTokenIds: [POLICY, OTHER_POLICY] })).toEqual({ + ok: true, + burnedPolicyIds: [POLICY], + }); + }); + + it("returns nothing for transactions without a mint field or for unrelated burns", () => { + const plain = buildTxHex([{ address: WALLET, lovelace: "5000000" }]); + expect(findAuthTokenBurns({ txCbor: plain, authTokenIds: [POLICY] })).toEqual({ + ok: true, + burnedPolicyIds: [], + }); + + const unrelated = buildTxHex( + [{ address: WALLET, lovelace: "5000000" }], + [{ policy: OTHER_POLICY, quantity: "-1" }], + ); + expect(findAuthTokenBurns({ txCbor: unrelated, authTokenIds: [POLICY] })).toEqual({ + ok: true, + burnedPolicyIds: [], + }); + }); + + it("fails closed on undecodable CBOR", () => { + expect(findAuthTokenBurns({ txCbor: "zz", authTokenIds: [POLICY] })).toEqual({ + ok: false, + error: "transaction CBOR could not be decoded", + }); + }); +}); + +describe("auditAuthTokenBurn", () => { + const burnTx = buildTxHex( + [{ address: WALLET, lovelace: "5000000" }], + [{ policy: POLICY, quantity: "-10" }], + ); + const proxies = [{ authTokenId: POLICY, proxyAddress: PROXY }]; + + it("allows the burn when the proxy address is empty", async () => { + const fetchProxyUtxos = jest.fn(async (_address: string) => [] as never[]); + await expect( + auditAuthTokenBurn({ txCbor: burnTx, proxies, fetchProxyUtxos, context: "test" }), + ).resolves.toBeNull(); + expect(fetchProxyUtxos).toHaveBeenCalledWith(PROXY); + }); + + it("refuses the burn while the proxy address still holds UTxOs (funds would be locked forever)", async () => { + const fetchProxyUtxos = jest.fn(async (_address: string) => [{} as never, {} as never]); + const message = await auditAuthTokenBurn({ + txCbor: burnTx, + proxies, + fetchProxyUtxos, + context: "test", + }); + expect(message).toMatch(/still holds 2 UTxO\(s\)/); + }); + + it("fails closed when the proxy balance cannot be fetched", async () => { + const fetchProxyUtxos = jest.fn(async (_address: string): Promise => { + throw new Error("blockfrost down"); + }); + const message = await auditAuthTokenBurn({ + txCbor: burnTx, + proxies, + fetchProxyUtxos, + context: "test", + }); + expect(message).toMatch(/Unable to verify the proxy address is empty/); + }); + + it("does not touch the chain for transactions that burn nothing", async () => { + const fetchProxyUtxos = jest.fn(async (_address: string) => [] as never[]); + const txCbor = buildTxHex([{ address: WALLET, tokens: [{ policy: POLICY, quantity: "1" }] }]); + await expect( + auditAuthTokenBurn({ txCbor, proxies, fetchProxyUtxos, context: "test" }), + ).resolves.toBeNull(); + expect(fetchProxyUtxos).not.toHaveBeenCalled(); + }); +}); + +describe("auditBuiltProxyTx", () => { + it("returns null for a safe transaction and a formatted message otherwise", () => { + const safe = buildTxHex([{ address: WALLET, tokens: [{ policy: POLICY, quantity: "1" }] }]); + expect( + auditBuiltProxyTx({ txCbor: safe, authTokenIds: [POLICY], walletAddress: WALLET, context: "test" }), + ).toBeNull(); + + const unsafe = buildTxHex([{ address: PROXY, tokens: [{ policy: POLICY, quantity: "1" }] }]); + const message = auditBuiltProxyTx({ + txCbor: unsafe, + authTokenIds: [POLICY], + walletAddress: WALLET, + context: "test", + }); + expect(message).toMatch(/^Transaction moves a proxy auth token away from the wallet address/); + expect(formatAuthTokenAuditError({ violations: ["x"] })).toBe( + "Transaction moves a proxy auth token away from the wallet address: x", + ); + }); +}); diff --git a/src/__tests__/freeUtxos.bot.test.ts b/src/__tests__/freeUtxos.bot.test.ts index ff4de4dd..f630b390 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,44 @@ 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, authTokenId: null }, + ]); + }); + + it("flags UTxOs that carry a proxy auth token (every proxy row, active or not)", async () => { + const policy = "d".repeat(56); + (findProxiesMock as any).mockResolvedValue([{ authTokenId: policy }]); + (cachedFetchAddressUTxOsMock as any).mockResolvedValue([ + { + input: { txHash: "tok", outputIndex: 0 }, + output: { address: "addr_test1walletscript", amount: [{ unit: "lovelace", quantity: "2000000" }, { unit: policy, quantity: "1" }] }, + }, + { + input: { txHash: "ada", outputIndex: 1 }, + output: { address: "addr_test1walletscript", amount: [{ unit: "lovelace", quantity: "5000000" }] }, + }, + ]); + const req = { + method: "GET", + headers: makeBearerAuth(), + query: { walletId: "wallet-1", address: BOT_TEST_ADDRESS }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + // No isActive filter: the DB flag must not hide a token policy that still + // exists on-chain from coin-selecting callers. + expect(findProxiesMock).toHaveBeenCalledWith({ + where: { walletId: "wallet-1" }, + select: { authTokenId: true }, + }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith([ + expect.objectContaining({ input: { txHash: "tok", outputIndex: 0 }, authToken: true, authTokenId: policy }), + expect.objectContaining({ input: { txHash: "ada", outputIndex: 1 }, authToken: false, authTokenId: null }), + ]); }); it("falls back to direct provider fetch when cached UTxO lookup fails", async () => { @@ -165,7 +205,9 @@ 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, authTokenId: null }, + ]); }); it("returns an empty array when the provider has no UTxOs for the script address", async () => { diff --git a/src/__tests__/preSignProxyAudit.test.ts b/src/__tests__/preSignProxyAudit.test.ts new file mode 100644 index 00000000..c92c6665 --- /dev/null +++ b/src/__tests__/preSignProxyAudit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { + PRE_SIGN_AUDIT_UNAVAILABLE, + runPreSignProxyAudit, +} from "@/utils/preSignProxyAudit"; + +type AuditInput = { walletId: string; txCbor: string }; + +describe("runPreSignProxyAudit (browser gate before wallet.signTx)", () => { + it("passes through a clean audit", async () => { + const fetchAudit = jest.fn(async (_input: AuditInput) => ({ error: null as string | null })); + await expect( + runPreSignProxyAudit({ fetchAudit, walletId: "w", txCbor: "aa" }), + ).resolves.toEqual({ ok: true }); + expect(fetchAudit).toHaveBeenCalledWith({ walletId: "w", txCbor: "aa" }); + }); + + it("surfaces the server's refusal", async () => { + const fetchAudit = jest.fn(async (_input: AuditInput) => ({ error: "nope" as string | null })); + await expect( + runPreSignProxyAudit({ fetchAudit, walletId: "w", txCbor: "aa" }), + ).resolves.toEqual({ ok: false, message: "nope" }); + }); + + it("fails closed when the audit cannot be reached", async () => { + const fetchAudit = jest.fn(async (_input: AuditInput): Promise<{ error: string | null }> => { + throw new Error("network"); + }); + await expect( + runPreSignProxyAudit({ fetchAudit, walletId: "w", txCbor: "aa" }), + ).resolves.toEqual({ ok: false, message: PRE_SIGN_AUDIT_UNAVAILABLE }); + }); +}); diff --git a/src/__tests__/proxy-cross-verify.test.ts b/src/__tests__/proxy-cross-verify.test.ts index 6bda8a28..68b5b6f7 100644 --- a/src/__tests__/proxy-cross-verify.test.ts +++ b/src/__tests__/proxy-cross-verify.test.ts @@ -95,9 +95,8 @@ describe("browser vs direct builder — identical tx calls", () => { }); 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. + // Both paths derive the proxy address the same way now (stake credential = + // the proxy script itself); pin that so they can never drift apart again. const tmpProvider = createMockProvider(); const { mesh: tmpMesh } = createMeshMock(tmpProvider); const tmpContract = new MeshProxyContract( @@ -106,6 +105,7 @@ describe("browser vs direct builder — identical tx calls", () => { ); const proxyAddress = tmpContract.proxyAddress!; const authTokenPolicyId = tmpContract.getAuthTokenPolicyId(); + expect(proxyAddress).toBe(deriveProxyScripts({ paramUtxo: PARAM_UTXO, network: 0 }).proxyAddress); const authTokenUtxo: UTxO = { input: { txHash: "f".repeat(64), outputIndex: 0 }, diff --git a/src/__tests__/proxyAuthTokenAudit.server.test.ts b/src/__tests__/proxyAuthTokenAudit.server.test.ts new file mode 100644 index 00000000..22dca987 --- /dev/null +++ b/src/__tests__/proxyAuthTokenAudit.server.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { csl } from "@meshsdk/core-csl"; +import type { Wallet as DbWallet } from "@prisma/client"; + +const resolveWalletScriptAddressMock = jest.fn<(...args: unknown[]) => string>(); +const getProviderMock = jest.fn<(network: number) => unknown>(); + +jest.mock("@/lib/server/walletScriptAddress", () => ({ + __esModule: true, + resolveWalletScriptAddress: resolveWalletScriptAddressMock, +})); + +jest.mock("@/utils/get-provider", () => ({ + __esModule: true, + getProvider: getProviderMock, +})); + +const POLICY = "d".repeat(56); + +function enterpriseAddress(keyHashHex: string): string { + return csl.EnterpriseAddress.new( + csl.NetworkInfo.testnet_preview().network_id(), + csl.Credential.from_keyhash(csl.Ed25519KeyHash.from_hex(keyHashHex)), + ) + .to_address() + .to_bech32(); +} + +const WALLET = enterpriseAddress("1".repeat(56)); +const PROXY = enterpriseAddress("2".repeat(56)); +const STRANGER = enterpriseAddress("3".repeat(56)); + +function buildTxHex(args: { + outputs: { address: string; token?: string }[]; + burn?: boolean; +}): string { + const inputs = csl.TransactionInputs.new(); + inputs.add( + csl.TransactionInput.new(csl.TransactionHash.from_bytes(Buffer.from("00".repeat(32), "hex")), 0), + ); + const outputs = csl.TransactionOutputs.new(); + for (const spec of args.outputs) { + const value = csl.Value.new(csl.BigNum.from_str("2000000")); + if (spec.token) { + const assets = csl.Assets.new(); + assets.insert(csl.AssetName.new(Buffer.alloc(0)), csl.BigNum.from_str(spec.token)); + const multiasset = csl.MultiAsset.new(); + multiasset.insert(csl.ScriptHash.from_hex(POLICY), assets); + value.set_multiasset(multiasset); + } + outputs.add(csl.TransactionOutput.new(csl.Address.from_bech32(spec.address), value)); + } + const body = csl.TransactionBody.new(inputs, outputs, csl.BigNum.from_str("200000"), undefined); + if (args.burn) { + const mintAssets = csl.MintAssets.new(); + mintAssets.insert(csl.AssetName.new(Buffer.alloc(0)), csl.Int.from_str("-10")); + const mint = csl.Mint.new(); + mint.insert(csl.ScriptHash.from_hex(POLICY), mintAssets); + body.set_mint(mint); + } + return csl.Transaction.new(body, csl.TransactionWitnessSet.new(), undefined).to_hex(); +} + +const wallet = { id: "wallet-1" } as DbWallet; + +type FindManyArgs = { where: Record; select?: Record }; + +function dbWithProxies(rows: { authTokenId: string; proxyAddress: string; isActive: boolean }[]) { + const findMany = jest.fn(async (args: FindManyArgs) => { + // Mirror Prisma: only filter by what the query asks for. + return rows + .filter((row) => !("isActive" in args.where) || row.isActive === args.where.isActive) + .map(({ authTokenId, proxyAddress }) => ({ authTokenId, proxyAddress })); + }); + return { db: { proxy: { findMany } } as never, findMany }; +} + +let auditPendingTxForProxyWallet: typeof import("@/lib/server/proxyAuthTokenAudit")["auditPendingTxForProxyWallet"]; + +beforeEach(async () => { + jest.clearAllMocks(); + resolveWalletScriptAddressMock.mockReturnValue(WALLET); + ({ auditPendingTxForProxyWallet } = await import("@/lib/server/proxyAuthTokenAudit")); +}); + +describe("auditPendingTxForProxyWallet", () => { + it("skips wallets without proxies without resolving anything", async () => { + const { db } = dbWithProxies([]); + const txCbor = buildTxHex({ outputs: [{ address: STRANGER, token: "1" }] }); + await expect( + auditPendingTxForProxyWallet(db, { wallet, signerAddress: WALLET, txCbor, context: "t" }), + ).resolves.toBeNull(); + expect(resolveWalletScriptAddressMock).not.toHaveBeenCalled(); + }); + + it("audits every proxy row, including ones flagged inactive in the DB", async () => { + const { db, findMany } = dbWithProxies([ + { authTokenId: POLICY, proxyAddress: PROXY, isActive: false }, + ]); + const txCbor = buildTxHex({ outputs: [{ address: STRANGER, token: "1" }] }); + const message = await auditPendingTxForProxyWallet(db, { + wallet, + signerAddress: WALLET, + txCbor, + context: "t", + }); + expect(message).toMatch(/moves a proxy auth token away from the wallet address/); + expect(findMany).toHaveBeenCalledWith({ + where: { walletId: "wallet-1" }, + select: { authTokenId: true, proxyAddress: true }, + }); + }); + + it("accepts a safe transaction", async () => { + const { db } = dbWithProxies([{ authTokenId: POLICY, proxyAddress: PROXY, isActive: true }]); + const txCbor = buildTxHex({ outputs: [{ address: WALLET, token: "1" }, { address: PROXY }] }); + await expect( + auditPendingTxForProxyWallet(db, { wallet, signerAddress: WALLET, txCbor, context: "t" }), + ).resolves.toBeNull(); + expect(getProviderMock).not.toHaveBeenCalled(); + }); + + it("refuses a burn while the proxy address still holds UTxOs", async () => { + const fetchAddressUTxOs = jest.fn(async (_address: string) => [{} as never]); + getProviderMock.mockReturnValue({ fetchAddressUTxOs }); + const { db } = dbWithProxies([{ authTokenId: POLICY, proxyAddress: PROXY, isActive: true }]); + const txCbor = buildTxHex({ outputs: [{ address: WALLET }], burn: true }); + const message = await auditPendingTxForProxyWallet(db, { + wallet, + signerAddress: WALLET, + txCbor, + context: "t", + }); + expect(message).toMatch(/still holds 1 UTxO\(s\)/); + expect(getProviderMock).toHaveBeenCalledWith(0); + expect(fetchAddressUTxOs).toHaveBeenCalledWith(PROXY); + }); + + it("accepts a burn once the proxy address is empty and honours an injected fetcher", async () => { + const fetchProxyUtxos = jest.fn(async (_address: string) => [] as never[]); + const { db } = dbWithProxies([{ authTokenId: POLICY, proxyAddress: PROXY, isActive: true }]); + const txCbor = buildTxHex({ outputs: [{ address: WALLET }], burn: true }); + await expect( + auditPendingTxForProxyWallet(db, { + wallet, + signerAddress: WALLET, + txCbor, + context: "t", + fetchProxyUtxos, + }), + ).resolves.toBeNull(); + expect(fetchProxyUtxos).toHaveBeenCalledWith(PROXY); + expect(getProviderMock).not.toHaveBeenCalled(); + }); + + it("fails closed when the wallet address cannot be resolved", async () => { + resolveWalletScriptAddressMock.mockImplementation(() => { + throw new Error("no script"); + }); + const { db } = dbWithProxies([{ authTokenId: POLICY, proxyAddress: PROXY, isActive: true }]); + const txCbor = buildTxHex({ outputs: [{ address: WALLET }] }); + const message = await auditPendingTxForProxyWallet(db, { + wallet, + signerAddress: WALLET, + txCbor, + context: "t", + }); + expect(message).toMatch(/wallet script address resolution failed/); + }); +}); diff --git a/src/__tests__/proxyAuthTokenConservation.test.ts b/src/__tests__/proxyAuthTokenConservation.test.ts new file mode 100644 index 00000000..f560de31 --- /dev/null +++ b/src/__tests__/proxyAuthTokenConservation.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it } from "@jest/globals"; +import type { UTxO } from "@meshsdk/core"; +import { countAuthTokens } from "@/lib/proxy/utxoUtils"; +import { + buildProxyCleanupSweepTx, + buildProxyDRepCertificateTx, + buildProxySpendTx, + buildProxyVoteTx, + deriveProxyScripts, +} from "@/lib/proxy/txBuilders"; + +/** + * Regression suite for the proxy auth-token leak (PR #396). + * + * The on-chain proxy validator passes whenever *some* output carries the auth-token + * policy and never checks who holds it. `buildProxySpendTx` sends change to the proxy + * address, so an auth token that is spent but not explicitly returned to the wallet + * lands at the proxy address and makes it drainable by anyone. These tests pin the + * off-chain invariant: every token that enters as an input leaves at the wallet + * address (split 1 per output for spend/DRep/vote, bundled for the cleanup sweep), + * and nothing token-bearing goes anywhere else. + */ + +const WALLET = "addr_test_wallet"; +const 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 } } as UTxO; +} + +function createRecordingBuilder() { + const txOuts: Array<{ address: string; amount: UTxO["output"]["amount"] }> = []; + const txIns: Array<{ txHash: string; outputIndex: number }> = []; + let changeAddress: string | undefined; + const builder: Record = {}; + const chain = () => builder; + for (const name of [ + "spendingPlutusScriptV3", + "txInScript", + "txInInlineDatumPresent", + "txInRedeemerValue", + "txInCollateral", + "drepRegistrationCertificate", + "drepUpdateCertificate", + "drepDeregistrationCertificate", + "certificateScript", + "certificateRedeemerValue", + "votePlutusScriptV3", + "vote", + "voteScript", + "voteRedeemerValue", + ]) { + builder[name] = chain; + } + builder.txIn = (txHash: string, outputIndex: number) => { + txIns.push({ txHash, outputIndex }); + return builder; + }; + builder.txOut = (address: string, amount: UTxO["output"]["amount"]) => { + txOuts.push({ address, amount }); + return builder; + }; + builder.changeAddress = (address: string) => { + changeAddress = address; + return builder; + }; + return { + builder: builder as never, + txOuts, + txIns, + getChangeAddress: () => changeAddress, + }; +} + +const scripts = deriveProxyScripts({ paramUtxo: PARAM_UTXO, network: 0 }); +const TOKEN = scripts.authTokenId; + +const tokenUtxo = (txHash: string, quantity = "1", lovelace = "2000000") => + mkUtxo( + WALLET, + [ + { unit: "lovelace", quantity: lovelace }, + { unit: TOKEN, quantity }, + ], + txHash, + ); +const adaUtxo = (txHash: string, lovelace = "5000000") => + mkUtxo(WALLET, [{ unit: "lovelace", quantity: lovelace }], txHash); +const collateral = mkUtxo(WALLET, [{ unit: "lovelace", quantity: "5000000" }], "f".repeat(64)); + +function tokenOutputs(txOuts: Array<{ address: string; amount: UTxO["output"]["amount"] }>) { + return txOuts.filter((out) => out.amount.some((asset) => asset.unit === TOKEN)); +} + +function tokensSentTo( + txOuts: Array<{ address: string; amount: UTxO["output"]["amount"] }>, + address: string, +): bigint { + return txOuts + .filter((out) => out.address === address) + .flatMap((out) => out.amount) + .filter((asset) => asset.unit === TOKEN) + .reduce((sum, asset) => sum + BigInt(asset.quantity), 0n); +} + +describe("countAuthTokens", () => { + it("sums the auth-token unit across UTxOs and ignores duplicates by ref", () => { + const a = tokenUtxo("b".repeat(64)); + const b = tokenUtxo("c".repeat(64), "2"); + expect(countAuthTokens([a, b, a, adaUtxo("d".repeat(64))], TOKEN)).toBe(3n); + expect(countAuthTokens([adaUtxo("d".repeat(64))], TOKEN)).toBe(0n); + }); +}); + +describe("buildProxySpendTx auth-token conservation", () => { + const defaultProxyUtxos = () => [ + mkUtxo(scripts.proxyAddress, [{ unit: "lovelace", quantity: "10000000" }], "e".repeat(64)), + ]; + const spend = ( + walletUtxos: UTxO[], + authTokenUtxo: UTxO, + proxyUtxos: UTxO[] = defaultProxyUtxos(), + ) => { + const rec = createRecordingBuilder(); + buildProxySpendTx({ + txBuilder: rec.builder, + network: 0, + proxyAddress: scripts.proxyAddress, + paramUtxo: PARAM_UTXO, + walletUtxos, + proxyUtxos, + authTokenUtxo, + collateral, + outputs: [{ address: "addr_test_payee", unit: "lovelace", amount: "1000000" }], + walletAddress: WALLET, + }); + return rec; + }; + + it("returns exactly one token to the wallet for a single designated token input", () => { + const designated = tokenUtxo("b".repeat(64)); + const rec = spend([designated, adaUtxo("c".repeat(64))], designated); + + expect(rec.getChangeAddress()).toBe(scripts.proxyAddress); + expect(tokensSentTo(rec.txOuts, WALLET)).toBe(1n); + expect(tokensSentTo(rec.txOuts, scripts.proxyAddress)).toBe(0n); + }); + + it("returns every stray token in walletUtxos to the wallet as separate 1-token outputs (the PR #396 leak)", () => { + const designated = tokenUtxo("b".repeat(64)); + const stray1 = tokenUtxo("c".repeat(64)); + const stray2 = tokenUtxo("d".repeat(64)); + const rec = spend([stray1, adaUtxo("9".repeat(64)), stray2], designated); + + const inputTokens = countAuthTokens([designated, stray1, stray2], TOKEN); + expect(inputTokens).toBe(3n); + expect(tokensSentTo(rec.txOuts, WALLET)).toBe(3n); + expect(tokensSentTo(rec.txOuts, scripts.proxyAddress)).toBe(0n); + expect(tokenOutputs(rec.txOuts)).toHaveLength(3); + for (const out of tokenOutputs(rec.txOuts)) { + expect(out.address).toBe(WALLET); + expect(out.amount).toEqual([{ unit: TOKEN, quantity: "1" }]); + } + // All three token UTxOs are spent exactly once. + expect(rec.txIns.filter((i) => [designated, stray1, stray2].some((u) => u.input.txHash === i.txHash))).toHaveLength(3); + }); + + it("splits a designated UTxO that bundles several tokens back into 1-token outputs", () => { + const designated = tokenUtxo("b".repeat(64), "2"); + const rec = spend([], designated); + + expect(tokensSentTo(rec.txOuts, WALLET)).toBe(2n); + expect(tokenOutputs(rec.txOuts)).toHaveLength(2); + expect(tokensSentTo(rec.txOuts, scripts.proxyAddress)).toBe(0n); + }); + + it("does not double-spend the designated UTxO when it is also listed in walletUtxos", () => { + const designated = tokenUtxo("b".repeat(64)); + const rec = spend([designated, adaUtxo("c".repeat(64))], designated); + + expect(rec.txIns.filter((i) => i.txHash === designated.input.txHash)).toHaveLength(1); + expect(tokensSentTo(rec.txOuts, WALLET)).toBe(1n); + }); + + it("refuses to build when no input carries the auth token", () => { + const notAToken = adaUtxo("b".repeat(64)); + expect(() => spend([], notAToken)).toThrow(/requires an input holding the proxy auth token/); + }); + + it("recovers a token that was previously leaked to the proxy address instead of recycling it into proxy change", () => { + const designated = tokenUtxo("b".repeat(64)); + const leakedProxyUtxo = mkUtxo( + scripts.proxyAddress, + [ + { unit: "lovelace", quantity: "10000000" }, + { unit: TOKEN, quantity: "1" }, + ], + "e".repeat(64), + ); + const rec = spend([designated], designated, [leakedProxyUtxo]); + + // 1 from the wallet + 1 recovered from the proxy, both back at the wallet, split. + expect(tokensSentTo(rec.txOuts, WALLET)).toBe(2n); + expect(tokenOutputs(rec.txOuts)).toHaveLength(2); + expect(tokensSentTo(rec.txOuts, scripts.proxyAddress)).toBe(0n); + expect(rec.getChangeAddress()).toBe(scripts.proxyAddress); + }); +}); + +describe("buildProxyCleanupSweepTx auth-token conservation", () => { + it("accepts all wallet token UTxOs (cleanup passes every token) and bundles them into the swept-value output", () => { + const designated = tokenUtxo("b".repeat(64)); + const others = Array.from({ length: 9 }, (_, i) => + tokenUtxo((i + 1).toString(16).padStart(1, "0").repeat(64)), + ); + const rec = createRecordingBuilder(); + const result = buildProxyCleanupSweepTx({ + txBuilder: rec.builder, + network: 0, + paramUtxo: PARAM_UTXO, + proxyAddress: scripts.proxyAddress, + proxyUtxos: [mkUtxo(scripts.proxyAddress, [{ unit: "lovelace", quantity: "2500000" }], "e".repeat(64))], + walletUtxos: [designated, ...others], + authTokenUtxo: designated, + collateral, + walletAddress: WALLET, + }); + + expect(result).toEqual({ sweptProxyUtxos: "1", preservedAuthTokens: "10" }); + expect(rec.getChangeAddress()).toBe(WALLET); + expect(tokensSentTo(rec.txOuts, WALLET)).toBe(10n); + expect(tokensSentTo(rec.txOuts, scripts.proxyAddress)).toBe(0n); + // One output carrying the swept proxy ADA *and* all tokens. The burn that follows + // is typically funded with token UTxOs alone, so the tokens must keep real ADA with + // them: a token-only output would get bare min-ADA and the burn could not pay its + // fee plus a min-ADA change ("UTxO Fully Depleted"). Splitting 1-per-output would + // starve the sweep itself the same way. + expect(rec.txOuts).toEqual([ + { + address: WALLET, + amount: [ + { unit: "lovelace", quantity: "2500000" }, + { unit: TOKEN, quantity: "10" }, + ], + }, + ]); + }); + + it("recovers a token that had previously leaked to the proxy address", () => { + const designated = tokenUtxo("b".repeat(64)); + const leakedAtProxy = mkUtxo( + scripts.proxyAddress, + [ + { unit: "lovelace", quantity: "3000000" }, + { unit: TOKEN, quantity: "1" }, + ], + "e".repeat(64), + ); + const rec = createRecordingBuilder(); + const result = buildProxyCleanupSweepTx({ + txBuilder: rec.builder, + network: 0, + paramUtxo: PARAM_UTXO, + proxyAddress: scripts.proxyAddress, + proxyUtxos: [leakedAtProxy], + walletUtxos: [designated], + authTokenUtxo: designated, + collateral, + walletAddress: WALLET, + }); + + expect(result.preservedAuthTokens).toBe("2"); + expect(tokensSentTo(rec.txOuts, WALLET)).toBe(2n); + expect(tokensSentTo(rec.txOuts, scripts.proxyAddress)).toBe(0n); + // Both tokens (the designated one and the recovered leak) ride in the single + // swept-value output; the leaked token is not double counted from the proxy value. + expect(rec.txOuts).toEqual([ + { + address: WALLET, + amount: [ + { unit: "lovelace", quantity: "3000000" }, + { unit: TOKEN, quantity: "2" }, + ], + }, + ]); + }); +}); + +describe("DRep certificate and vote builders conserve tokens", () => { + it("vote returns every token selected as funding", () => { + const designated = tokenUtxo("b".repeat(64), "1", "1500000"); + const stray = tokenUtxo("c".repeat(64), "1", "3000000"); + const rec = createRecordingBuilder(); + buildProxyVoteTx({ + txBuilder: rec.builder, + network: 0, + paramUtxo: PARAM_UTXO, + walletUtxos: [designated, stray], + authTokenUtxo: designated, + collateral, + walletAddress: WALLET, + votes: [{ proposalId: `${"e".repeat(64)}#0`, voteKind: "Abstain" }], + }); + + // Both UTxOs were needed to reach the 2 ADA minimum, so both tokens come back. + expect(tokensSentTo(rec.txOuts, WALLET)).toBe(2n); + expect(tokenOutputs(rec.txOuts)).toHaveLength(2); + }); + + it("DRep deregister returns every token selected as funding", () => { + const designated = tokenUtxo("b".repeat(64), "1", "1500000"); + const stray = tokenUtxo("c".repeat(64), "1", "3000000"); + const rec = createRecordingBuilder(); + buildProxyDRepCertificateTx({ + txBuilder: rec.builder, + network: 0, + paramUtxo: PARAM_UTXO, + walletUtxos: [designated, stray], + authTokenUtxo: designated, + collateral, + walletAddress: WALLET, + action: "deregister", + }); + + expect(tokensSentTo(rec.txOuts, WALLET)).toBe(2n); + expect(tokenOutputs(rec.txOuts)).toHaveLength(2); + }); +}); diff --git a/src/__tests__/proxyBotSelection.test.ts b/src/__tests__/proxyBotSelection.test.ts index af1f8314..2febe4d9 100644 --- a/src/__tests__/proxyBotSelection.test.ts +++ b/src/__tests__/proxyBotSelection.test.ts @@ -86,6 +86,53 @@ describe("proxy bot UTxO selection", () => { ).toThrow(/bot payment-address collateral UTxO/); }); + it("cleanup selection passes every auth-token UTxO plus ADA funding when tokens carry only min-ADA", () => { + // Wallet state after a sweep whose token output was bundled: one min-ADA token + // UTxO, the swept proxy value and plain change. The burn must still be fundable. + const refs = selectAuthTokenRefs({ + walletUtxos: [ + mkUtxo("999920", "token-bundle", 0, "10"), + mkUtxo("8789186", "swept"), + mkUtxo("5234629981", "change"), + ], + collateralUtxos: [mkUtxo("6000000", "collateral", 0, undefined, "addr_test_signer_1")], + authTokenId: AUTH_TOKEN_ID, + includeAllAuthTokens: true, + }); + + expect(refs.utxoRefs).toEqual([ + { txHash: "token-bundle", outputIndex: 0 }, + { txHash: "change", outputIndex: 0 }, + ]); + expect(refs.collateralRef).toEqual({ txHash: "collateral", outputIndex: 0 }); + }); + + it("cleanup selection adds no funding when the token UTxOs already cover the budget", () => { + const refs = selectAuthTokenRefs({ + walletUtxos: [ + ...Array.from({ length: 10 }, (_, i) => mkUtxo("1000000", `token-${i}`, 0, "1")), + mkUtxo("300000000", "funding"), + ], + collateralUtxos: [mkUtxo("6000000", "collateral", 0, undefined, "addr_test_signer_1")], + authTokenId: AUTH_TOKEN_ID, + includeAllAuthTokens: true, + }); + + expect(refs.utxoRefs).toHaveLength(10); + expect(refs.utxoRefs).not.toContainEqual({ txHash: "funding", outputIndex: 0 }); + }); + + it("rejects cleanup selection when the wallet cannot fund the fee and change", () => { + expect(() => + selectAuthTokenRefs({ + walletUtxos: [mkUtxo("999920", "token-bundle", 0, "10"), mkUtxo("500000", "dust")], + collateralUtxos: [mkUtxo("6000000", "collateral", 0, undefined, "addr_test_signer_1")], + authTokenId: AUTH_TOKEN_ID, + includeAllAuthTokens: true, + }), + ).toThrow(/proxy cleanup requires 4 ADA in selected wallet inputs/); + }); + it("adds funding inputs for DRep register while keeping collateral separate", () => { const refs = selectDRepRegisterRefs({ walletUtxos: [ diff --git a/src/__tests__/proxyCleanup.bot.test.ts b/src/__tests__/proxyCleanup.bot.test.ts index ebab1e37..c3175cc8 100644 --- a/src/__tests__/proxyCleanup.bot.test.ts +++ b/src/__tests__/proxyCleanup.bot.test.ts @@ -27,6 +27,12 @@ const completeTxWithFreshCostModelsMock: jest.Mock = jest.fn(); const completeMock: jest.Mock = jest.fn(); const getTxBuilderMock: jest.Mock = jest.fn(); const fetchAddressUTxOsMock: jest.Mock = jest.fn(); +const auditBuiltProxyTxMock: jest.Mock = jest.fn(); + +jest.mock("@/lib/server/proxyAuthTokenAudit", () => ({ + __esModule: true, + auditBuiltProxyTx: auditBuiltProxyTxMock, +})); const proxy = { id: "proxy-1", @@ -122,6 +128,7 @@ jest.mock("@/lib/server/proxyTxBuilders", () => ({ buildProxyCleanupSweepTx: buildProxyCleanupSweepTxMock, buildProxyCleanupTx: buildProxyCleanupTxMock, deriveProxyScripts: deriveProxyScriptsMock, + proxyStakeCredentialFromAddress: () => ({ kind: "self" }), })); let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; @@ -158,6 +165,7 @@ beforeEach(() => { getTxBuilderMock.mockReturnValue({ complete: completeMock, meshTxBuilderBody: {} }); (completeTxWithFreshCostModelsMock as any).mockResolvedValue("fresh-tx-cbor"); (createPendingMultisigTransactionMock as any).mockResolvedValue({ id: "tx-1" }); + auditBuiltProxyTxMock.mockReturnValue(null); }); function cleanupRequest(body: Record): NextApiRequest { diff --git a/src/__tests__/proxyDRepInfo.test.ts b/src/__tests__/proxyDRepInfo.test.ts index dcb5a0d7..3e957e9c 100644 --- a/src/__tests__/proxyDRepInfo.test.ts +++ b/src/__tests__/proxyDRepInfo.test.ts @@ -58,6 +58,7 @@ jest.mock("@/lib/server/proxyAccess", () => ({ jest.mock("@/lib/server/proxyTxBuilders", () => ({ __esModule: true, deriveProxyScripts: deriveProxyScriptsMock, + proxyStakeCredentialFromAddress: () => ({ kind: "self" }), })); let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; diff --git a/src/__tests__/proxySetup.bot.test.ts b/src/__tests__/proxySetup.bot.test.ts index b739d8f7..a8f5f626 100644 --- a/src/__tests__/proxySetup.bot.test.ts +++ b/src/__tests__/proxySetup.bot.test.ts @@ -18,6 +18,12 @@ 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 auditBuiltProxyTxMock: jest.Mock = jest.fn(); + +jest.mock("@/lib/server/proxyAuthTokenAudit", () => ({ + __esModule: true, + auditBuiltProxyTx: auditBuiltProxyTxMock, +})); jest.mock("@/lib/cors", () => ({ __esModule: true, @@ -114,9 +120,36 @@ beforeEach(() => { getTxBuilderMock.mockReturnValue(txBuilder); (completeTxWithFreshCostModelsMock as any).mockResolvedValue("fresh-tx-cbor"); (createPendingMultisigTransactionMock as any).mockResolvedValue({ id: "tx-1" }); + auditBuiltProxyTxMock.mockReturnValue(null); }); describe("proxySetup bot API", () => { + it("audits the finished CBOR for auth-token outputs and refuses to persist on failure", async () => { + auditBuiltProxyTxMock.mockReturnValue("Transaction moves a proxy auth token away from the wallet address: x"); + const req = { + method: "POST", + headers: makeBearerAuth(), + body: { + walletId: "wallet-1", + address: makeBotJwtPayload().address, + utxoRefs: [{ txHash: "aa", outputIndex: 0 }], + collateralRef: { txHash: "bb", outputIndex: 1 }, + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(auditBuiltProxyTxMock).toHaveBeenCalledWith({ + txCbor: "fresh-tx-cbor", + authTokenIds: ["policy"], + walletAddress: "addr_test_wallet_script", + context: "proxySetup", + }); + expect(res.status).toHaveBeenCalledWith(500); + expect(createPendingMultisigTransactionMock).not.toHaveBeenCalled(); + }); + it("rejects invalid initialProxyLovelace before resolving UTxOs", async () => { const req = { method: "POST", diff --git a/src/__tests__/proxyTxBuilders.test.ts b/src/__tests__/proxyTxBuilders.test.ts index f58ce062..690bbb68 100644 --- a/src/__tests__/proxyTxBuilders.test.ts +++ b/src/__tests__/proxyTxBuilders.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@jest/globals"; +import { deserializeAddress } from "@meshsdk/core"; import type { UTxO } from "@meshsdk/core"; import { buildProxyDRepCertificateTx, @@ -7,6 +8,9 @@ import { buildProxyVoteTx, buildProxySetupTx, DEFAULT_PROXY_SETUP_LOVELACE, + deriveProxyScripts, + findProxyAddressInOutputs, + proxyStakeCredentialFromAddress, } from "@/lib/proxy/txBuilders"; const mkUtxo = ( @@ -191,13 +195,17 @@ describe("buildProxySetupTx", () => { outputIndex: 2, address: setup.proxyAddress, }); - expect(sweepBuilder.txOuts).toContainEqual({ - address: "addr_test_wallet", - amount: expect.arrayContaining([ - { unit: "lovelace", quantity: "2500000" }, - { unit: setup.authTokenId, quantity: "1" }, - ]), - }); + // Swept value and the preserved auth token share one wallet output so the token + // UTxO keeps enough ADA to fund the burn that follows. + expect(sweepBuilder.txOuts).toEqual([ + { + address: "addr_test_wallet", + amount: [ + { unit: "lovelace", quantity: "2500000" }, + { unit: setup.authTokenId, quantity: "1" }, + ], + }, + ]); expect(sweepBuilder.mints).toEqual([]); }); }); @@ -271,3 +279,82 @@ describe("proxy action funding validation", () => { ).toThrow(/proxy DRep deregister requires at least 2 ADA in selected wallet inputs, but only 1.2 ADA was selected/); }); }); + +describe("proxy address stake credential", () => { + const paramUtxo = { txHash: "a".repeat(64), outputIndex: 0 }; + + it("defaults new proxies to the proxy script itself as a script stake credential", () => { + const scripts = deriveProxyScripts({ paramUtxo, network: 0 }); + const parsed = deserializeAddress(scripts.proxyAddress); + expect(parsed.scriptHash).toBe(scripts.proxyScriptHash); + expect(parsed.stakeScriptCredentialHash).toBe(scripts.proxyScriptHash); + expect(parsed.stakeCredentialHash).toBe(""); + expect(scripts.proxyAddress.startsWith("addr_test1")).toBe(true); + }); + + it("round-trips existing proxies through proxyStakeCredentialFromAddress (self, key, none)", () => { + const self = deriveProxyScripts({ paramUtxo, network: 0 }); + expect(proxyStakeCredentialFromAddress(self.proxyAddress)).toEqual({ + kind: "script", + hash: self.proxyScriptHash, + }); + expect( + deriveProxyScripts({ paramUtxo, network: 0, stake: proxyStakeCredentialFromAddress(self.proxyAddress) }) + .proxyAddress, + ).toBe(self.proxyAddress); + + // Legacy v1 derivation: base address with a key-hash stake part. + const legacyKeyHash = "c08f0294ead5ab7ae0ce5471dd487007919297ba95230af22f25e575"; + const legacy = deriveProxyScripts({ paramUtxo, network: 0, stake: { kind: "key", hash: legacyKeyHash } }); + expect(legacy.proxyAddress).not.toBe(self.proxyAddress); + expect(proxyStakeCredentialFromAddress(legacy.proxyAddress)).toEqual({ kind: "key", hash: legacyKeyHash }); + expect( + deriveProxyScripts({ paramUtxo, network: 0, stake: proxyStakeCredentialFromAddress(legacy.proxyAddress) }) + .proxyAddress, + ).toBe(legacy.proxyAddress); + + // Legacy browser derivation: enterprise address. + const enterprise = deriveProxyScripts({ paramUtxo, network: 0, stake: { kind: "none" } }); + expect(enterprise.proxyAddress).not.toBe(self.proxyAddress); + expect(proxyStakeCredentialFromAddress(enterprise.proxyAddress)).toEqual({ kind: "none" }); + expect( + deriveProxyScripts({ paramUtxo, network: 0, stake: proxyStakeCredentialFromAddress(enterprise.proxyAddress) }) + .proxyAddress, + ).toBe(enterprise.proxyAddress); + + // The payment part, token policy and DRep id never depend on the stake part. + for (const variant of [legacy, enterprise]) { + expect(variant.authTokenId).toBe(self.authTokenId); + expect(variant.dRepId).toBe(self.dRepId); + expect(deserializeAddress(variant.proxyAddress).scriptHash).toBe(self.proxyScriptHash); + } + }); + + it("findProxyAddressInOutputs picks the output paid to the proxy script regardless of stake part", () => { + const self = deriveProxyScripts({ paramUtxo, network: 0 }); + const legacy = deriveProxyScripts({ + paramUtxo, + network: 0, + stake: { kind: "key", hash: "c08f0294ead5ab7ae0ce5471dd487007919297ba95230af22f25e575" }, + }); + const walletAddr = mkUtxo("addr_test_wallet", "1").output.address; + expect(findProxyAddressInOutputs([walletAddr, legacy.proxyAddress], self.proxyScriptHash)).toBe( + legacy.proxyAddress, + ); + expect(findProxyAddressInOutputs([walletAddr, "not-an-address"], self.proxyScriptHash)).toBeNull(); + }); + + it("buildProxySetupTx funds the self-staked proxy address", () => { + const { builder, txOuts } = createTxBuilderMock(); + const setup = buildProxySetupTx({ + txBuilder: builder as never, + network: 0, + walletUtxos: [mkUtxo("addr_test_wallet", "25000000", "b".repeat(64), 1)], + walletAddress: "addr_test_wallet", + collateral: mkUtxo("addr_test_collateral", "5000000", "d".repeat(64), 3), + }); + const expected = deriveProxyScripts({ paramUtxo: setup.paramUtxo, network: 0 }); + expect(setup.proxyAddress).toBe(expected.proxyAddress); + expect(txOuts.some((out) => out.address === expected.proxyAddress)).toBe(true); + }); +}); diff --git a/src/__tests__/signTransaction.bot.test.ts b/src/__tests__/signTransaction.bot.test.ts index 070b1d95..7c4fe3eb 100644 --- a/src/__tests__/signTransaction.bot.test.ts +++ b/src/__tests__/signTransaction.bot.test.ts @@ -20,6 +20,12 @@ const findWalletMock: jest.Mock = jest.fn(); const findTransactionMock: jest.Mock = jest.fn(); const updateManyTransactionMock: jest.Mock = jest.fn(); const enqueueThresholdReachedMock: jest.Mock = jest.fn(); +const auditPendingTxForProxyWalletMock: jest.Mock = jest.fn(); + +jest.mock("@/lib/server/proxyAuthTokenAudit", () => ({ + __esModule: true, + auditPendingTxForProxyWallet: auditPendingTxForProxyWalletMock, +})); jest.mock("@/lib/notifications/center", () => ({ __esModule: true, @@ -133,6 +139,9 @@ beforeEach(() => { numRequiredSigners: 2, type: "atLeast", }); + // mockReset (not just clear) so a test that returns early does not leave the + // second once-value queued for the next test. + findTransactionMock.mockReset(); (findTransactionMock as any) .mockResolvedValueOnce({ id: "tx-1", @@ -173,6 +182,7 @@ beforeEach(() => { }); (updateManyTransactionMock as any).mockResolvedValue({ count: 1 }); (enqueueThresholdReachedMock as any).mockResolvedValue([]); + (auditPendingTxForProxyWalletMock as any).mockResolvedValue(null); }); describe("signTransaction bot API", () => { @@ -240,6 +250,43 @@ describe("signTransaction bot API", () => { expect(res.status).toHaveBeenCalledWith(403); }); + it("refuses to add a witness when the proxy auth-token audit fails", async () => { + (auditPendingTxForProxyWalletMock as any).mockResolvedValue( + "Transaction moves a proxy auth token away from the wallet address: output #1", + ); + const req = { + method: "POST", + headers: makeBearerAuth(), + body: { + walletId: "wallet-1", + transactionId: "tx-1", + address: BOT_TEST_ADDRESS, + signature: "aa".repeat(64), + key: "bb".repeat(64), + broadcast: false, + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + await handler(req, res); + + expect(auditPendingTxForProxyWalletMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + wallet: expect.objectContaining({ id: "wallet-1" }), + signerAddress: BOT_TEST_ADDRESS, + txCbor: "deadbeef", + context: "signTransaction", + }), + ); + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ + error: "Transaction moves a proxy auth token away from the wallet address: output #1", + }); + // Refused before any witness work or persistence. + expect(createVkeyWitnessFromHexMock).not.toHaveBeenCalled(); + expect(updateManyTransactionMock).not.toHaveBeenCalled(); + }); + it("records bot witness on happy path", async () => { const req = { method: "POST", diff --git a/src/__tests__/signTransaction.test.ts b/src/__tests__/signTransaction.test.ts index 478803c5..73a0528c 100644 --- a/src/__tests__/signTransaction.test.ts +++ b/src/__tests__/signTransaction.test.ts @@ -157,6 +157,20 @@ jest.unstable_mockModule( }), ); +// Proxy auth-token pre-sign audit: no active proxies in these fixtures, so it is a +// no-op. Mocked so the handler does not pull CSL/Mesh into this ESM-mocked module graph. +const auditPendingTxForProxyWalletMock = jest.fn< + (db: unknown, args: unknown) => Promise +>(); + +jest.unstable_mockModule( + '@/lib/server/proxyAuthTokenAudit', + () => ({ + __esModule: true, + auditPendingTxForProxyWallet: auditPendingTxForProxyWalletMock, + }), +); + const resolvePaymentKeyHashMock = jest.fn<(address: string) => string>(); jest.unstable_mockModule( @@ -424,6 +438,8 @@ beforeEach(() => { dbTransactionUpdateManyMock.mockReset(); dbTaskPayoutUpdateManyMock.mockReset(); dbTaskPayoutUpdateManyMock.mockResolvedValue({ count: 0 }); + auditPendingTxForProxyWalletMock.mockReset(); + auditPendingTxForProxyWalletMock.mockResolvedValue(null); getProviderMock.mockReset(); addressToNetworkMock.mockReset(); shouldSubmitMultisigTxMock.mockReset(); diff --git a/src/__tests__/trpc/proxyAuthTokenGate.test.ts b/src/__tests__/trpc/proxyAuthTokenGate.test.ts new file mode 100644 index 00000000..68dcda50 --- /dev/null +++ b/src/__tests__/trpc/proxyAuthTokenGate.test.ts @@ -0,0 +1,126 @@ +import { beforeAll, beforeEach, afterEach, describe, expect, it, jest } from "@jest/globals"; + +import { realTestAddresses } from "../testUtils"; +import { cleanupFixtures, seedWallet } from "./fixtures"; +import { makeWalletCtx } from "./helpers"; + +jest.mock("superjson", () => ({ + __esModule: true, + default: { + serialize: (value: unknown) => value, + deserialize: (value: unknown) => value, + }, +})); + +jest.mock("@/server/auth", () => ({ + __esModule: true, + getServerAuthSession: jest.fn(), +})); + +// The audit itself is covered by proxyAuthTokenAudit.server.test.ts; here we pin +// that the router actually consults it and refuses on a non-null answer. +const auditPendingTxForProxyWalletMock = jest.fn< + (...args: unknown[]) => Promise +>(); +jest.mock("@/lib/server/proxyAuthTokenAudit", () => ({ + __esModule: true, + auditPendingTxForProxyWallet: auditPendingTxForProxyWalletMock, +})); + +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 REFUSAL = "Transaction moves a proxy auth token away from the wallet address: output #1"; + +describeWithDb("proxy auth-token gate in the transaction router", () => { + beforeAll(async () => { + ({ createCaller } = await import("@/server/api/root")); + ({ db } = await import("@/server/db")); + }); + + beforeEach(() => { + auditPendingTxForProxyWalletMock.mockReset(); + auditPendingTxForProxyWalletMock.mockResolvedValue(null); + }); + + afterEach(async () => { + if (walletId) { + await cleanupFixtures(db, { walletId }); + walletId = undefined; + } + }); + + async function seedCaller() { + ({ walletId } = await seedWallet(db, SIGNER)); + return createCaller(makeWalletCtx(SIGNER, db) as any); + } + + it("createTransaction consults the audit and refuses an unsafe transaction before persisting", async () => { + const caller = await seedCaller(); + auditPendingTxForProxyWalletMock.mockResolvedValue(REFUSAL); + + await expect( + caller.transaction.createTransaction({ + walletId: walletId!, + txJson: JSON.stringify({ body: {} }), + signedAddresses: [SIGNER], + txCbor: "deadbeef", + state: 0, + }), + ).rejects.toMatchObject({ code: "PRECONDITION_FAILED", message: REFUSAL }); + + expect(auditPendingTxForProxyWalletMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + signerAddress: SIGNER, + txCbor: "deadbeef", + context: "transaction.createTransaction", + }), + ); + await expect(db.transaction.findMany({ where: { walletId } })).resolves.toHaveLength(0); + }); + + it("createTransaction persists when the audit passes", async () => { + const caller = await seedCaller(); + + const created = await caller.transaction.createTransaction({ + walletId: walletId!, + txJson: JSON.stringify({ body: {} }), + signedAddresses: [], + txCbor: "deadbeef", + state: 0, + }); + + expect(created.id).toBeTruthy(); + expect(auditPendingTxForProxyWalletMock).toHaveBeenCalledTimes(1); + }); + + it("preSignProxyAudit returns the audit message so the browser can refuse before signing", async () => { + const caller = await seedCaller(); + auditPendingTxForProxyWalletMock.mockResolvedValue(REFUSAL); + + await expect( + caller.transaction.preSignProxyAudit({ walletId: walletId!, txCbor: "deadbeef" }), + ).resolves.toEqual({ error: REFUSAL }); + + auditPendingTxForProxyWalletMock.mockResolvedValue(null); + await expect( + caller.transaction.preSignProxyAudit({ walletId: walletId!, txCbor: "deadbeef" }), + ).resolves.toEqual({ error: null }); + }); + + it("preSignProxyAudit requires wallet access", async () => { + await seedCaller(); + const stranger = createCaller(makeWalletCtx(realTestAddresses.address2, db) as any); + + await expect( + stranger.transaction.preSignProxyAudit({ walletId: walletId!, txCbor: "deadbeef" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(auditPendingTxForProxyWalletMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/trpc/transactionAuth.test.ts b/src/__tests__/trpc/transactionAuth.test.ts index e0e99952..5a8d1dfb 100644 --- a/src/__tests__/trpc/transactionAuth.test.ts +++ b/src/__tests__/trpc/transactionAuth.test.ts @@ -24,6 +24,9 @@ const makeMockDb = () => ({ findUnique: jest.fn(), findMany: jest.fn(), }, + // createTransaction runs the proxy auth-token audit before persisting; with + // no proxy rows it returns early without touching the chain. + proxy: { findMany: jest.fn(async (_args: unknown) => [] as never[]) }, }); const wallet = (overrides: Record = {}) => ({ @@ -113,6 +116,10 @@ describe("transaction router authorization", () => { state: 0, }), ).resolves.toMatchObject({ id: "tx-1" }); + + expect(mockDb.proxy.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { walletId: "wallet-1" } }), + ); }); it("allows a signer from the wallet-session context", async () => { @@ -130,5 +137,9 @@ describe("transaction router authorization", () => { state: 0, }), ).resolves.toMatchObject({ id: "tx-1" }); + + expect(mockDb.proxy.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { walletId: "wallet-1" } }), + ); }); }); diff --git a/src/__tests__/tx-builders/proxy.test.ts b/src/__tests__/tx-builders/proxy.test.ts index 00bdf349..90435f61 100644 --- a/src/__tests__/tx-builders/proxy.test.ts +++ b/src/__tests__/tx-builders/proxy.test.ts @@ -577,10 +577,10 @@ describe("MeshProxyContract — builder delegation", () => { expect(spendSpy).toHaveBeenCalledWith( expect.objectContaining({ proxyAddress: contract.proxyAddress }), ); - // Confirm proxyAddress was actually set (not still undefined) + // Confirm proxyAddress was actually set (not still undefined) and that the + // builder and the contract agree on it (same stake-credential derivation). expect(contract.proxyAddress).toBeDefined(); - // Confirm the stored address was used (not setupResult.proxyAddress which - // comes from the builder's DEFAULT_PROXY_STAKE_CREDENTIAL fallback) + expect(contract.proxyAddress).toBe(setupResult.proxyAddress); expect(setupResult.paramUtxo).toEqual(LARGE_UTXO.input); }); }); diff --git a/src/components/multisig/proxy/ProxyControl.tsx b/src/components/multisig/proxy/ProxyControl.tsx index 4b34886d..c85a399f 100644 --- a/src/components/multisig/proxy/ProxyControl.tsx +++ b/src/components/multisig/proxy/ProxyControl.tsx @@ -516,11 +516,11 @@ export default function ProxyControl() { }, { paramUtxo: JSON.parse(proxy.paramUtxo) as { txHash: string; outputIndex: number }, + proxyAddress: proxy.proxyAddress, }, appWallet?.scriptCbor ?? undefined, ); - tempContract.proxyAddress = proxy.proxyAddress; - + // Get DRep ID and status const drepId = tempContract.getDrepId(); const status = tempContract.getDrepStatus(); @@ -665,10 +665,10 @@ export default function ProxyControl() { }, { paramUtxo: JSON.parse(proxy.paramUtxo) as { txHash: string; outputIndex: number }, + proxyAddress: proxy.proxyAddress, }, appWallet?.scriptCbor ?? undefined, ); - selectedProxyContract.proxyAddress = proxy.proxyAddress; // Pass multisig inputs to spend as well const { utxos, walletAddress } = await getMsInputs(); diff --git a/src/components/multisig/proxy/common.ts b/src/components/multisig/proxy/common.ts index f620dafd..006cc7fb 100644 --- a/src/components/multisig/proxy/common.ts +++ b/src/components/multisig/proxy/common.ts @@ -32,7 +32,9 @@ export class MeshTxInitiator { fetcher, wallet, networkId = 0, - stakeCredential = "c08f0294ead5ab7ae0ce5471dd487007919297ba95230af22f25e575", + // No default stake credential: a hardcoded key hash would hand the staking + // rights of every script address built here to whoever holds that key. + stakeCredential, version = 2, }: MeshTxInitiatorInput) { this.mesh = mesh; diff --git a/src/components/multisig/proxy/offchain.ts b/src/components/multisig/proxy/offchain.ts index 8530b30d..9bfb2700 100644 --- a/src/components/multisig/proxy/offchain.ts +++ b/src/components/multisig/proxy/offchain.ts @@ -1,7 +1,6 @@ import { mOutputReference } from "@meshsdk/common"; import { resolveScriptHash, - serializePlutusScript, applyParamsToScript, resolveScriptHashDRepId, } from "@meshsdk/core"; @@ -19,6 +18,9 @@ import { buildProxyCleanupTx, buildProxyCleanupSweepTx, deriveProxyScripts, + PROXY_STAKE_SELF, + proxyStakeCredentialFromAddress, + type ProxyStakeCredential, } from "@/lib/proxy/txBuilders"; import { selectProxyUtxosForOutputs, @@ -32,7 +34,12 @@ const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes export class MeshProxyContract extends MeshTxInitiator { paramUtxo: UTxO["input"] = { outputIndex: 0, txHash: "" }; proxyAddress?: string; - stakeCredential?: string | undefined; + /** + * Stake part of the proxy address. `self` (proxy script hash) for new setups; + * for an existing proxy it is read from the stored `proxyAddress` so the derived + * address always matches the UTxOs on-chain. See `ProxyStakeCredential`. + */ + stake: ProxyStakeCredential = PROXY_STAKE_SELF; networkId: number; msCbor?: string; // Multisig script cbor @@ -40,7 +47,7 @@ export class MeshProxyContract extends MeshTxInitiator { reset() { this.paramUtxo = { outputIndex: 0, txHash: "" }; this.proxyAddress = undefined; - this.stakeCredential = undefined; + this.stake = PROXY_STAKE_SELF; } // Static method to clear DRep status cache @@ -69,14 +76,11 @@ export class MeshProxyContract extends MeshTxInitiator { }; setProxyAddress = () => { - const proxyAddress = serializePlutusScript( - { - code: this.getProxyCbor(), - version: "V3", - }, - this.stakeCredential, - this.networkId, - ).address; + const proxyAddress = deriveProxyScripts({ + paramUtxo: this.paramUtxo, + network: this.networkId, + stake: this.stake, + }).proxyAddress; this.proxyAddress = proxyAddress; return proxyAddress; }; @@ -85,18 +89,38 @@ export class MeshProxyContract extends MeshTxInitiator { inputs: MeshTxInitiatorInput, contract: { paramUtxo?: UTxO["input"]; + /** + * Stored address of an existing proxy. When given, the stake credential is + * read from it instead of defaulting to `self`, so proxies created with an + * older derivation keep resolving to the address that holds their funds. + */ + proxyAddress?: string; }, msCbor?: string, ) { super(inputs); - this.stakeCredential = inputs.stakeCredential; this.networkId = inputs.networkId ?? 0; this.msCbor = msCbor; + if (contract.proxyAddress) { + this.stake = proxyStakeCredentialFromAddress(contract.proxyAddress); + } + // Set the proxyAddress if paramUtxo is provided if (contract.paramUtxo) { this.paramUtxo = contract.paramUtxo; this.setProxyAddress(); + if (contract.proxyAddress && this.proxyAddress !== contract.proxyAddress) { + // The stored address is where the funds are; keep it and surface the + // inconsistency instead of silently talking to an empty address. + console.warn("MeshProxyContract: stored proxy address differs from derived", { + stored: contract.proxyAddress, + derived: this.proxyAddress, + }); + this.proxyAddress = contract.proxyAddress; + } + } else if (contract.proxyAddress) { + this.proxyAddress = contract.proxyAddress; } } @@ -142,7 +166,7 @@ export class MeshProxyContract extends MeshTxInitiator { walletAddress, collateral, multisigScriptCbor: this.msCbor, - stakeCredential: this.stakeCredential, + stake: this.stake, }); this.paramUtxo = result.paramUtxo; @@ -174,7 +198,7 @@ export class MeshProxyContract extends MeshTxInitiator { const scripts = deriveProxyScripts({ paramUtxo: this.paramUtxo, network: this.networkId, - stakeCredential: this.stakeCredential, + stake: this.stake, }); const selectedProxyUtxos = selectProxyUtxosForOutputs( proxyUtxos, @@ -195,7 +219,7 @@ export class MeshProxyContract extends MeshTxInitiator { outputs, walletAddress, multisigScriptCbor: this.msCbor, - stakeCredential: this.stakeCredential, + stake: this.stake, }); return this.mesh; @@ -226,7 +250,7 @@ export class MeshProxyContract extends MeshTxInitiator { const scripts = deriveProxyScripts({ paramUtxo: this.paramUtxo, network: this.networkId, - stakeCredential: this.stakeCredential, + stake: this.stake, }); const authTokenUtxo = selectAuthTokenUtxo(utxos, scripts.authTokenId); @@ -242,7 +266,7 @@ export class MeshProxyContract extends MeshTxInitiator { anchorUrl, anchorJson, multisigScriptCbor: this.msCbor, - stakeCredential: this.stakeCredential, + stake: this.stake, }); return this.mesh; @@ -534,7 +558,7 @@ export class MeshProxyContract extends MeshTxInitiator { const scripts = deriveProxyScripts({ paramUtxo: this.paramUtxo, network: this.networkId, - stakeCredential: this.stakeCredential, + stake: this.stake, }); const authTokenUtxo = selectAuthTokenUtxo(utxos, scripts.authTokenId); @@ -548,7 +572,7 @@ export class MeshProxyContract extends MeshTxInitiator { walletAddress, votes, multisigScriptCbor: this.msCbor, - stakeCredential: this.stakeCredential, + stake: this.stake, }); return this.mesh; @@ -577,7 +601,7 @@ export class MeshProxyContract extends MeshTxInitiator { const scripts = deriveProxyScripts({ paramUtxo: this.paramUtxo, network: this.networkId, - stakeCredential: this.stakeCredential, + stake: this.stake, }); if (proxyUtxos.length > 0) { @@ -593,7 +617,7 @@ export class MeshProxyContract extends MeshTxInitiator { collateral, walletAddress, multisigScriptCbor: this.msCbor, - stakeCredential: this.stakeCredential, + stake: this.stake, }); } else { buildProxyCleanupTx({ @@ -605,7 +629,7 @@ export class MeshProxyContract extends MeshTxInitiator { walletAddress, authTokenId: scripts.authTokenId, multisigScriptCbor: this.msCbor, - stakeCredential: this.stakeCredential, + stake: this.stake, }); } diff --git a/src/components/pages/wallet/governance/ballot/ballot.tsx b/src/components/pages/wallet/governance/ballot/ballot.tsx index b380828d..eff96dda 100644 --- a/src/components/pages/wallet/governance/ballot/ballot.tsx +++ b/src/components/pages/wallet/governance/ballot/ballot.tsx @@ -312,10 +312,10 @@ export default function BallotCard({ }, { paramUtxo: JSON.parse(proxy.paramUtxo), + proxyAddress: proxy.proxyAddress, }, appWallet.scriptCbor, ); - proxyContract.proxyAddress = proxy.proxyAddress; // Prepare votes array const votes = selectedBallot.items.map((proposalId: string, index: number) => { diff --git a/src/components/pages/wallet/governance/card-info.tsx b/src/components/pages/wallet/governance/card-info.tsx index 08d608ea..f235e92c 100644 --- a/src/components/pages/wallet/governance/card-info.tsx +++ b/src/components/pages/wallet/governance/card-info.tsx @@ -98,10 +98,10 @@ export default function CardInfo({ appWallet, manualUtxos }: { appWallet: Wallet }, { paramUtxo: JSON.parse(selectedProxy.paramUtxo || '{}'), + proxyAddress: selectedProxy.proxyAddress, }, appWallet.scriptCbor, ); - proxyContract.proxyAddress = selectedProxy.proxyAddress; // Get DRep ID const drepId = proxyContract.getDrepId(); diff --git a/src/components/pages/wallet/governance/drep/registerDrep.tsx b/src/components/pages/wallet/governance/drep/registerDrep.tsx index f01393e5..1a1fc901 100644 --- a/src/components/pages/wallet/governance/drep/registerDrep.tsx +++ b/src/components/pages/wallet/governance/drep/registerDrep.tsx @@ -283,10 +283,10 @@ export default function RegisterDRep({ onClose }: RegisterDRepProps = {}) { }, { paramUtxo: JSON.parse(proxy.paramUtxo), + proxyAddress: proxy.proxyAddress, }, appWallet.scriptCbor, ); - proxyContract.proxyAddress = proxy.proxyAddress; // Register DRep using proxy const txHex = await proxyContract.registerProxyDrep(anchorUrl, anchorJson, utxos, walletAddress); diff --git a/src/components/pages/wallet/governance/drep/retire.tsx b/src/components/pages/wallet/governance/drep/retire.tsx index 6bf19204..99fa82aa 100644 --- a/src/components/pages/wallet/governance/drep/retire.tsx +++ b/src/components/pages/wallet/governance/drep/retire.tsx @@ -103,10 +103,10 @@ export default function Retire({ appWallet, manualUtxos }: { appWallet: Wallet; }, { paramUtxo: JSON.parse(proxy.paramUtxo), + proxyAddress: proxy.proxyAddress, }, appWallet.scriptCbor, ); - proxyContract.proxyAddress = proxy.proxyAddress; // Deregister DRep using proxy const txHex = await proxyContract.deregisterProxyDrep(utxos, walletAddress); diff --git a/src/components/pages/wallet/governance/drep/updateDrep.tsx b/src/components/pages/wallet/governance/drep/updateDrep.tsx index 12efbee1..b47225ac 100644 --- a/src/components/pages/wallet/governance/drep/updateDrep.tsx +++ b/src/components/pages/wallet/governance/drep/updateDrep.tsx @@ -143,10 +143,10 @@ export default function UpdateDRep({ onClose }: UpdateDRepProps = {}) { }, { paramUtxo: JSON.parse(proxy.paramUtxo), + proxyAddress: proxy.proxyAddress, }, appWallet.scriptCbor, ); - proxyContract.proxyAddress = proxy.proxyAddress; // Update DRep using proxy const txHex = await proxyContract.updateProxyDrep(anchorUrl, anchorJson, utxos, walletAddress); diff --git a/src/components/pages/wallet/governance/proposal/voteButtton.tsx b/src/components/pages/wallet/governance/proposal/voteButtton.tsx index 5bb702e5..89bc1725 100644 --- a/src/components/pages/wallet/governance/proposal/voteButtton.tsx +++ b/src/components/pages/wallet/governance/proposal/voteButtton.tsx @@ -162,10 +162,10 @@ export default function VoteButton({ }, { paramUtxo: JSON.parse(proxy.paramUtxo), + proxyAddress: proxy.proxyAddress, }, appWallet.scriptCbor, ); - proxyContract.proxyAddress = proxy.proxyAddress; // Prepare vote data const voteData = { diff --git a/src/components/pages/wallet/governance/proposals.tsx b/src/components/pages/wallet/governance/proposals.tsx index 8f6bc65c..53d75017 100644 --- a/src/components/pages/wallet/governance/proposals.tsx +++ b/src/components/pages/wallet/governance/proposals.tsx @@ -146,10 +146,10 @@ export default function AllProposals({ appWallet, utxos, selectedBallotId, onSel }, { paramUtxo: JSON.parse(selectedProxy.paramUtxo || '{}'), + proxyAddress: selectedProxy.proxyAddress, }, appWallet.scriptCbor, ); - proxyContract.proxyAddress = selectedProxy.proxyAddress; const drepId = proxyContract.getDrepId(); setProxyDrepId(drepId); } catch (error) { diff --git a/src/components/pages/wallet/transactions/transaction-card.tsx b/src/components/pages/wallet/transactions/transaction-card.tsx index 25c52756..f3031636 100644 --- a/src/components/pages/wallet/transactions/transaction-card.tsx +++ b/src/components/pages/wallet/transactions/transaction-card.tsx @@ -60,6 +60,7 @@ import useProposalTitles from "@/hooks/useProposalTitles"; import { isDraftCompatible } from "@/lib/tx-draft/from-tx-json"; import PayoutTasksBadge from "@/components/pages/wallet/tasks/payout-tasks-badge"; import { getProvider } from "@/utils/get-provider"; +import { runPreSignProxyAudit } from "@/utils/preSignProxyAudit"; import { useSiteStore } from "@/lib/zustand/site"; import { filterWitnessesToScripts, @@ -428,6 +429,26 @@ export default function TransactionCard({ try { setLoading(true); + // Proxy safety: ask the server whether this tx moves a proxy auth token away + // from the wallet (or burns tokens while the proxy holds funds) BEFORE the + // wallet signs. If we are the final signer we broadcast right after signing, + // so a refusal from updateTransaction would come too late. + const preSign = await runPreSignProxyAudit({ + fetchAudit: (input) => ctx.transaction.preSignProxyAudit.fetch(input), + walletId: appWallet.id, + txCbor: transaction.txCbor, + }); + if (!preSign.ok) { + setLoading(false); + toast({ + title: "Transaction refused", + description: preSign.message, + duration: 10000, + variant: "destructive", + }); + return; + } + const signerWitnessPayload = await activeWallet.signTx(transaction.txCbor, true); const mergeResult = mergeSignerWitnesses( diff --git a/src/hooks/useTransaction.ts b/src/hooks/useTransaction.ts index 8eb5bb3d..8da1456a 100644 --- a/src/hooks/useTransaction.ts +++ b/src/hooks/useTransaction.ts @@ -14,6 +14,7 @@ import { import { completeTxWithFreshCostModels } from "@/lib/completeTxWithFreshCostModels"; import { applyMetadataMessage } from "@/lib/tx-draft/metadata"; import { getProvider } from "@/utils/get-provider"; +import { runPreSignProxyAudit } from "@/utils/preSignProxyAudit"; export default function useTransaction() { const ctx = api.useUtils(); @@ -124,6 +125,25 @@ export default function useTransaction() { throw new Error("No wallet available for signing transaction"); } + // Proxy safety: refuse before the wallet signs. For a 1-of-N wallet the + // proposer broadcasts immediately, so this is the only point where an + // auth-token leak or a premature burn can still be stopped. + const preSign = await runPreSignProxyAudit({ + fetchAudit: (input) => ctx.transaction.preSignProxyAudit.fetch(input), + walletId: appWallet.id, + txCbor: unsignedTx, + }); + if (!preSign.ok) { + setLoading(false); + toast({ + title: "Transaction refused", + description: preSign.message, + duration: 10000, + variant: "destructive", + }); + return; + } + const signerWitnessPayload = await activeWallet.signTx(unsignedTx, true); const mergeResult = mergeSignerWitnesses(unsignedTx, signerWitnessPayload); if (mergeResult.invalidVkeyPubKeysHex.length > 0) { @@ -187,7 +207,7 @@ export default function useTransaction() { duration: 10000, }); }, - [appWallet, userAddress, activeWallet, createTransaction, setLoading, toast, network], + [appWallet, userAddress, activeWallet, createTransaction, setLoading, toast, network, ctx], ); return { newTransaction }; diff --git a/src/lib/proxy/authTokenAudit.ts b/src/lib/proxy/authTokenAudit.ts new file mode 100644 index 00000000..295d0ab6 --- /dev/null +++ b/src/lib/proxy/authTokenAudit.ts @@ -0,0 +1,159 @@ +import { csl } from "@meshsdk/core-csl"; + +export type AuthTokenAuditResult = + | { ok: true; tokenOutputs: number } + | { ok: false; violations: string[] }; + +function addressToHex(address: string): string { + const trimmed = address.trim(); + try { + return csl.Address.from_bech32(trimmed).to_hex(); + } catch { + return csl.Address.from_hex(trimmed).to_hex(); + } +} + +/** + * Independent safety check on a finished transaction for a wallet with proxy auth + * tokens. It decodes the CBOR (not the builder's own bookkeeping) and verifies that + * every output carrying one of `authTokenIds` is at the multisig wallet address. + * Burns are allowed (no token outputs); anything that moves a token elsewhere is a + * violation. + * + * Why this exists: the deployed proxy validator passes whenever *some* output carries + * the auth-token policy, with no signer check. A token that lands anywhere other than + * the wallet address (notably the proxy address itself, as change) makes the proxy + * drainable by anyone. Builders conserve tokens by construction; this is the + * defense-in-depth layer that catches a regression, an imported transaction, or a + * transaction built by external tooling before signers put a witness on it. + * + * Deliberately address-only: how many tokens share one wallet output is a pool-hygiene + * concern handled by the builders (split for spend/DRep/vote, bundled for the cleanup + * sweep), not a security boundary. Refusing bundled outputs here would also block + * ordinary wallet transfers whose coin selection happened to sweep token UTxOs into + * change. + * + * Fails closed: undecodable CBOR or an unparsable wallet address is a violation. + */ +export function auditAuthTokenOutputs(args: { + txCbor: string; + authTokenIds: string[]; + walletAddress: string; +}): AuthTokenAuditResult { + const policyIds = Array.from( + new Set(args.authTokenIds.map((id) => id.trim().toLowerCase()).filter(Boolean)), + ); + if (policyIds.length === 0) { + return { ok: true, tokenOutputs: 0 }; + } + + let walletAddressHex: string; + try { + walletAddressHex = addressToHex(args.walletAddress); + } catch { + return { ok: false, violations: ["wallet address could not be parsed"] }; + } + + let outputs: ReturnType["body"]>["outputs"]>; + try { + outputs = csl.Transaction.from_hex(args.txCbor.trim()).body().outputs(); + } catch { + return { ok: false, violations: ["transaction CBOR could not be decoded"] }; + } + + const violations: string[] = []; + let tokenOutputs = 0; + + for (let i = 0; i < outputs.len(); i++) { + const output = outputs.get(i); + const multiasset = output.amount().multiasset(); + if (!multiasset) continue; + + for (const policyId of policyIds) { + let assets: ReturnType; + try { + assets = multiasset.get(csl.ScriptHash.from_hex(policyId)); + } catch { + violations.push(`auth token policy ${policyId} is not a valid script hash`); + continue; + } + if (!assets) continue; + + let quantity = 0n; + const names = assets.keys(); + for (let j = 0; j < names.len(); j++) { + const amount = assets.get(names.get(j)); + if (amount) quantity += BigInt(amount.to_str()); + } + if (quantity === 0n) continue; + + tokenOutputs += 1; + const outputAddressHex = output.address().to_hex(); + if (outputAddressHex !== walletAddressHex) { + violations.push( + `output #${i} sends ${quantity.toString()} auth token(s) of ${policyId} to an address other than the multisig wallet`, + ); + } + } + } + + return violations.length > 0 ? { ok: false, violations } : { ok: true, tokenOutputs }; +} + +export function formatAuthTokenAuditError(result: { violations: string[] }): string { + return `Transaction moves a proxy auth token away from the wallet address: ${result.violations.join("; ")}`; +} + +export type AuthTokenBurnScan = + | { ok: true; burnedPolicyIds: string[] } + | { ok: false; error: string }; + +/** + * Lists which of `authTokenIds` the transaction burns (negative mint quantity). + * Used to gate the cleanup burn: once the 10 tokens are gone the proxy validator can + * never pass again, so anything still sitting at the proxy address is locked forever. + * Callers combine this with a live look at the proxy address before signing. + * + * Fails closed on undecodable CBOR. + */ +export function findAuthTokenBurns(args: { + txCbor: string; + authTokenIds: string[]; +}): AuthTokenBurnScan { + const policyIds = Array.from( + new Set(args.authTokenIds.map((id) => id.trim().toLowerCase()).filter(Boolean)), + ); + if (policyIds.length === 0) return { ok: true, burnedPolicyIds: [] }; + + let mint: ReturnType["body"]>["mint"]>; + try { + mint = csl.Transaction.from_hex(args.txCbor.trim()).body().mint(); + } catch { + return { ok: false, error: "transaction CBOR could not be decoded" }; + } + if (!mint) return { ok: true, burnedPolicyIds: [] }; + + const burned: string[] = []; + for (const policyId of policyIds) { + let scriptHash: ReturnType; + try { + scriptHash = csl.ScriptHash.from_hex(policyId); + } catch { + return { ok: false, error: `auth token policy ${policyId} is not a valid script hash` }; + } + const entries = mint.get(scriptHash); + if (!entries) continue; + let burnsThisPolicy = false; + for (let i = 0; i < entries.len(); i++) { + const assets = entries.get(i); + if (!assets) continue; + const names = assets.keys(); + for (let j = 0; j < names.len(); j++) { + const amount = assets.get(names.get(j)); + if (amount && !amount.is_positive() && amount.to_str() !== "0") burnsThisPolicy = true; + } + } + if (burnsThisPolicy) burned.push(policyId); + } + return { ok: true, burnedPolicyIds: burned }; +} diff --git a/src/lib/proxy/txBuilders.ts b/src/lib/proxy/txBuilders.ts index 02265e50..242435d5 100644 --- a/src/lib/proxy/txBuilders.ts +++ b/src/lib/proxy/txBuilders.ts @@ -1,6 +1,7 @@ import { mConStr0, mConStr1, mOutputReference } from "@meshsdk/common"; import { applyParamsToScript, + deserializeAddress, hashDrepAnchor, resolveScriptHash, resolveScriptHashDRepId, @@ -9,13 +10,48 @@ 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, + countAuthTokens, + getLovelace, + sameUtxoRef, + selectSetupUtxo, +} from "./utxoUtils"; export const DEFAULT_PROXY_SETUP_LOVELACE = "1000000"; const PROXY_ACTION_MIN_LOVELACE = 2_000_000n; -const DEFAULT_PROXY_STAKE_CREDENTIAL = - "c08f0294ead5ab7ae0ce5471dd487007919297ba95230af22f25e575"; +/** + * Stake part of the proxy address. + * + * - `self` (default for new proxies): the proxy script's own hash as a *script* stake + * credential. Staking rewards and delegation of the ADA parked at the proxy are then + * gated by the proxy validator exactly like spending (any withdraw/cert purpose needs + * an auth-token output), so nobody outside the multisig holds rights over them. + * - `none`: enterprise address (what the browser class used to derive). + * - `key` / `script` with an explicit hash: whatever an existing proxy was created with. + * + * Existing proxies must always be derived from their stored `proxyAddress` via + * `proxyStakeCredentialFromAddress`, never from a default, or the derived address + * would not match the UTxOs on-chain. + */ +export type ProxyStakeCredential = + | { kind: "self" } + | { kind: "none" } + | { kind: "key" | "script"; hash: string }; + +export const PROXY_STAKE_SELF: ProxyStakeCredential = { kind: "self" }; + +export function proxyStakeCredentialFromAddress(proxyAddress: string): ProxyStakeCredential { + const parsed = deserializeAddress(proxyAddress); + if (parsed.stakeScriptCredentialHash) { + return { kind: "script", hash: parsed.stakeScriptCredentialHash }; + } + if (parsed.stakeCredentialHash) { + return { kind: "key", hash: parsed.stakeCredentialHash }; + } + return { kind: "none" }; +} export type ProxySetupInfo = { paramUtxo: UTxO["input"]; @@ -50,7 +86,8 @@ function assertSelectedLovelace(args: { export function deriveProxyScripts(args: { paramUtxo: UTxO["input"]; network: number; - stakeCredential?: string; + /** Defaults to `self`. Pass `proxyStakeCredentialFromAddress(stored)` for existing proxies. */ + stake?: ProxyStakeCredential; }) { const authTokenCbor = applyParamsToScript( blueprint.validators[0]!.compiledCode, @@ -60,23 +97,47 @@ export function deriveProxyScripts(args: { 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 stake = args.stake ?? PROXY_STAKE_SELF; + const script = { code: proxyCbor, version: "V3" as const }; + const proxyAddress = + stake.kind === "self" + ? serializePlutusScript(script, proxyScriptHash, args.network, true).address + : stake.kind === "none" + ? serializePlutusScript(script, undefined, args.network).address + : serializePlutusScript(script, stake.hash, args.network, stake.kind === "script").address; const dRepId = resolveScriptHashDRepId(proxyScriptHash); return { authTokenCbor, authTokenId, proxyCbor, + proxyScriptHash, proxyAddress, dRepId, }; } +/** + * Finds the address a setup transaction actually paid the proxy at, given the + * addresses of its outputs: the one whose payment credential is the proxy script. + * Lets chain-recovery code adopt proxies created with any stake-credential + * derivation without guessing. + */ +export function findProxyAddressInOutputs( + outputAddresses: string[], + proxyScriptHash: string, +): string | null { + for (const address of outputAddresses) { + try { + if (deserializeAddress(address).scriptHash === proxyScriptHash) return address; + } catch { + // not a parsable Shelley address; skip + } + } + return null; +} + function addScriptInput( txBuilder: MeshTxBuilder, utxo: UTxO, @@ -102,6 +163,57 @@ function addCollateral(txBuilder: MeshTxBuilder, collateral: UTxO) { ); } +/** + * Returns every auth token consumed by `inputs` to the multisig wallet. This is the + * off-chain safety invariant for the proxy: the on-chain validator does not check where + * the token goes, so a token that reached change (the proxy address, in + * `buildProxySpendTx`) would make the proxy drainable by anyone. + * + * - `split`: one lovelace-free output per token (Mesh fills min-ADA, as for setup's + * outputs). Used by spend/DRep/vote so the pool of independently spendable tokens + * (several pending proxy transactions at once) is preserved. + * - `bundle`: a single output holding all tokens together with `bundleWith` (the swept + * proxy value). Used by the cleanup sweep, which is immediately followed by the burn + * that needs all tokens in one transaction anyway. Riding in the swept-value output + * matters: a token-only output would get min-ADA and the burn, which callers fund with + * token UTxOs alone, could then not pay its fee plus a min-ADA change output. + */ +function returnAuthTokensToWallet(args: { + txBuilder: MeshTxBuilder; + inputs: UTxO[]; + authTokenId: string; + walletAddress: string; + context: string; + mode: "split" | "bundle"; + bundleWith?: UTxO["output"]["amount"]; +}): bigint { + const count = countAuthTokens(args.inputs, args.authTokenId); + if (count < 1n) { + throw new Error(`${args.context} requires an input holding the proxy auth token`); + } + if (args.mode === "bundle") { + const carried = (args.bundleWith ?? []).filter((asset) => asset.unit !== args.authTokenId); + args.txBuilder.txOut(args.walletAddress, [ + ...carried, + { unit: args.authTokenId, quantity: count.toString() }, + ]); + return count; + } + for (let i = 0n; i < count; i++) { + args.txBuilder.txOut(args.walletAddress, [ + { unit: args.authTokenId, quantity: "1" }, + ]); + } + return count; +} + +function withDesignatedAuthToken(authTokenUtxo: UTxO, walletUtxos: UTxO[]): UTxO[] { + return [ + authTokenUtxo, + ...walletUtxos.filter((utxo) => !sameUtxoRef(utxo.input, authTokenUtxo.input)), + ]; +} + export function buildProxySetupTx(args: { txBuilder: MeshTxBuilder; network: number; @@ -110,7 +222,7 @@ export function buildProxySetupTx(args: { collateral: UTxO; multisigScriptCbor?: string; initialProxyLovelace?: string; - stakeCredential?: string; + stake?: ProxyStakeCredential; }): ProxySetupInfo { const paramUtxo = selectSetupUtxo(args.walletUtxos); if (!paramUtxo) { @@ -122,7 +234,7 @@ export function buildProxySetupTx(args: { const scripts = deriveProxyScripts({ paramUtxo: paramUtxo.input, network: args.network, - stakeCredential: args.stakeCredential, + stake: args.stake, }); addScriptInput(args.txBuilder, paramUtxo, args.multisigScriptCbor); @@ -167,12 +279,12 @@ export function buildProxySpendTx(args: { outputs: { address: string; unit: string; amount: string }[]; walletAddress: string; multisigScriptCbor?: string; - stakeCredential?: string; + stake?: ProxyStakeCredential; }) { const scripts = deriveProxyScripts({ paramUtxo: args.paramUtxo, network: args.network, - stakeCredential: args.stakeCredential, + stake: args.stake, }); for (const proxyUtxo of args.proxyUtxos) { @@ -189,17 +301,25 @@ export function buildProxySpendTx(args: { .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); - } + const walletInputs = withDesignatedAuthToken(args.authTokenUtxo, args.walletUtxos ?? []); + for (const utxo of walletInputs) { + addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); } addCollateral(args.txBuilder, args.collateral); - args.txBuilder.txOut(args.walletAddress, [ - { unit: scripts.authTokenId, quantity: "1" }, - ]); + // Change goes to the proxy address below, so every auth token spent here must be + // explicitly returned to the wallet or it would land at the proxy unprotected. + // Proxy inputs are counted too: a token that was ever leaked to the proxy address + // rides back to the wallet instead of cycling through change (which the audit + // would refuse, blocking every spend until a full cleanup). + returnAuthTokensToWallet({ + txBuilder: args.txBuilder, + inputs: [...walletInputs, ...args.proxyUtxos], + authTokenId: scripts.authTokenId, + walletAddress: args.walletAddress, + context: "proxy spend", + mode: "split", + }); for (const output of args.outputs) { args.txBuilder.txOut(output.address, [ @@ -222,12 +342,12 @@ export function buildProxyDRepCertificateTx(args: { anchorUrl?: string; anchorJson?: object; multisigScriptCbor?: string; - stakeCredential?: string; + stake?: ProxyStakeCredential; }): { dRepId: string; anchorDataHash?: string } { const scripts = deriveProxyScripts({ paramUtxo: args.paramUtxo, network: args.network, - stakeCredential: args.stakeCredential, + stake: args.stake, }); let anchorDataHash: string | undefined; @@ -253,9 +373,14 @@ export function buildProxyDRepCertificateTx(args: { addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); } - args.txBuilder.txOut(args.walletAddress, [ - { unit: scripts.authTokenId, quantity: "1" }, - ]); + returnAuthTokensToWallet({ + txBuilder: args.txBuilder, + inputs: selectedUtxos, + authTokenId: scripts.authTokenId, + walletAddress: args.walletAddress, + context: `proxy DRep ${args.action}`, + mode: "split", + }); if (args.action === "register") { args.txBuilder.drepRegistrationCertificate(scripts.dRepId, { @@ -289,7 +414,7 @@ export function buildProxyVoteTx(args: { walletAddress: string; votes: ProxyVoteInput[]; multisigScriptCbor?: string; - stakeCredential?: string; + stake?: ProxyStakeCredential; }): { dRepId: string } { if (args.votes.length === 0) { throw new Error("votes must be a non-empty array"); @@ -298,7 +423,7 @@ export function buildProxyVoteTx(args: { const scripts = deriveProxyScripts({ paramUtxo: args.paramUtxo, network: args.network, - stakeCredential: args.stakeCredential, + stake: args.stake, }); addCollateral(args.txBuilder, args.collateral); @@ -314,9 +439,14 @@ export function buildProxyVoteTx(args: { addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); } - args.txBuilder.txOut(args.walletAddress, [ - { unit: scripts.authTokenId, quantity: "1" }, - ]); + returnAuthTokensToWallet({ + txBuilder: args.txBuilder, + inputs: selectedUtxos, + authTokenId: scripts.authTokenId, + walletAddress: args.walletAddress, + context: "proxy vote", + mode: "split", + }); for (const vote of args.votes) { const parsed = parseProposalId(vote.proposalId); @@ -353,12 +483,12 @@ export function buildProxyCleanupTx(args: { walletAddress: string; authTokenId: string; multisigScriptCbor?: string; - stakeCredential?: string; + stake?: ProxyStakeCredential; }): { burnedAuthTokens: string } { const scripts = deriveProxyScripts({ paramUtxo: args.paramUtxo, network: args.network, - stakeCredential: args.stakeCredential, + stake: args.stake, }); if (scripts.authTokenId !== args.authTokenId) { throw new Error("Stored proxy metadata does not match derived auth token"); @@ -424,7 +554,7 @@ export function buildProxyCleanupSweepTx(args: { collateral: UTxO; walletAddress: string; multisigScriptCbor?: string; - stakeCredential?: string; + stake?: ProxyStakeCredential; }): { sweptProxyUtxos: string; preservedAuthTokens: string } { if (args.proxyUtxos.length === 0) { throw new Error("proxy cleanup sweep requires at least one proxy UTxO"); @@ -433,7 +563,7 @@ export function buildProxyCleanupSweepTx(args: { const scripts = deriveProxyScripts({ paramUtxo: args.paramUtxo, network: args.network, - stakeCredential: args.stakeCredential, + stake: args.stake, }); for (const proxyUtxo of args.proxyUtxos) { @@ -453,24 +583,34 @@ export function buildProxyCleanupSweepTx(args: { .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); - } + const walletInputs = withDesignatedAuthToken(args.authTokenUtxo, args.walletUtxos); + for (const utxo of walletInputs) { + addScriptInput(args.txBuilder, utxo, args.multisigScriptCbor); } addCollateral(args.txBuilder, args.collateral); - args.txBuilder.txOut( - args.walletAddress, - aggregateUtxoAmounts(args.proxyUtxos, [ - { unit: scripts.authTokenId, quantity: "1" }, - ]), + // Swept proxy value and every auth token go back to the wallet in ONE output. Any + // auth token that was ever leaked to the proxy address is recovered here too, so + // include proxy inputs when counting tokens. Keeping the tokens with the swept ADA + // (rather than in a token-only output that would get bare min-ADA) is what lets the + // burn that follows pay its fee when the caller passes only auth-token UTxOs. + // Remaining wallet-input ADA goes to the wallet as change. + const sweptAmounts = aggregateUtxoAmounts(args.proxyUtxos).filter( + (asset) => asset.unit !== scripts.authTokenId, ); + const preservedAuthTokens = returnAuthTokensToWallet({ + txBuilder: args.txBuilder, + inputs: [...walletInputs, ...args.proxyUtxos], + authTokenId: scripts.authTokenId, + walletAddress: args.walletAddress, + context: "proxy cleanup sweep", + mode: "bundle", + bundleWith: sweptAmounts, + }); args.txBuilder.changeAddress(args.walletAddress); return { sweptProxyUtxos: args.proxyUtxos.length.toString(), - preservedAuthTokens: "1", + preservedAuthTokens: preservedAuthTokens.toString(), }; } diff --git a/src/lib/proxy/utxoUtils.ts b/src/lib/proxy/utxoUtils.ts index 487a8683..087e6dcc 100644 --- a/src/lib/proxy/utxoUtils.ts +++ b/src/lib/proxy/utxoUtils.ts @@ -19,6 +19,30 @@ export function sameUtxoRef(a: UTxO["input"], b: UTxO["input"]): boolean { return a.txHash === b.txHash && a.outputIndex === b.outputIndex; } +/** + * Total quantity of the proxy auth token (unit === policy id, empty asset name) + * across `utxos`. Duplicate UTxO refs are counted once. + * + * Security context: the proxy validator (`validators/proxy/spend.ak`) only checks that + * *some* output carries the auth-token policy; it has no signer check. The off-chain + * invariant that keeps the proxy safe is therefore that every auth token that enters a + * transaction as an input leaves it in an output at the multisig wallet address. + * Builders use this helper to size those outputs so no token can ever fall through to + * change (which, for `buildProxySpendTx`, is the proxy address itself). + */ +export function countAuthTokens(utxos: UTxO[], authTokenUnit: string): bigint { + const seen = new Set(); + let total = 0n; + for (const utxo of utxos) { + const key = `${utxo.input.txHash}#${utxo.input.outputIndex}`; + if (seen.has(key)) continue; + seen.add(key); + const quantity = utxo.output.amount.find((asset) => asset.unit === authTokenUnit)?.quantity; + if (quantity) total += BigInt(quantity); + } + return total; +} + /** * Greedy UTxO selection covering `outputs` plus an optional `feeBuffer` of lovelace. * Throws if the proxy balance is insufficient. diff --git a/src/lib/server/proxyAuthTokenAudit.ts b/src/lib/server/proxyAuthTokenAudit.ts new file mode 100644 index 00000000..f3868502 --- /dev/null +++ b/src/lib/server/proxyAuthTokenAudit.ts @@ -0,0 +1,159 @@ +import type { PrismaClient, Wallet as DbWallet } from "@prisma/client"; +import type { UTxO } from "@meshsdk/core"; +import { + auditAuthTokenOutputs, + findAuthTokenBurns, + formatAuthTokenAuditError, +} from "@/lib/proxy/authTokenAudit"; +import { resolveWalletScriptAddress } from "@/lib/server/walletScriptAddress"; +import { getProvider } from "@/utils/get-provider"; +import { addressToNetwork } from "@/utils/multisigSDK"; +import type { DbWalletWithLegacy } from "@/types/wallet"; + +type ProxyLookupDb = Pick; + +export type ProxyUtxoFetcher = (proxyAddress: string) => Promise; + +export { + auditAuthTokenOutputs, + findAuthTokenBurns, + formatAuthTokenAuditError, + type AuthTokenAuditResult, +} from "@/lib/proxy/authTokenAudit"; + +/** + * Post-build audit for the v1 proxy handlers. Returns an error message when the + * finished transaction would move a proxy auth token anywhere other than the multisig + * wallet address, or `null` when it is safe to persist as a pending transaction. + * + * A non-null result means our own builder produced an unsafe transaction, so callers + * should treat it as a server error, not a client error. + */ +export function auditBuiltProxyTx(args: { + txCbor: string; + authTokenIds: string[]; + walletAddress: string; + context: string; +}): string | null { + const result = auditAuthTokenOutputs({ + txCbor: args.txCbor, + authTokenIds: args.authTokenIds, + walletAddress: args.walletAddress, + }); + if (result.ok) return null; + console.error(`${args.context}: refusing to persist transaction`, { + violations: result.violations, + }); + return formatAuthTokenAuditError(result); +} + +/** + * Burn gate: if the transaction burns one of the wallet's auth-token policies, the + * matching proxy address must be empty on-chain. After the burn the proxy validator + * can never pass again, so any UTxO still there (a deposit that landed between the + * cleanup sweep and the burn) would be locked forever. + * + * Returns an error message, or `null` when there is nothing to object to. A failed + * chain lookup fails closed. + */ +export async function auditAuthTokenBurn(args: { + txCbor: string; + proxies: { authTokenId: string; proxyAddress: string }[]; + fetchProxyUtxos: ProxyUtxoFetcher; + context: string; +}): Promise { + const scan = findAuthTokenBurns({ + txCbor: args.txCbor, + authTokenIds: args.proxies.map((proxy) => proxy.authTokenId), + }); + if (!scan.ok) { + console.error(`${args.context}: burn scan failed`, { error: scan.error }); + return `Unable to verify proxy auth-token burn: ${scan.error}`; + } + if (scan.burnedPolicyIds.length === 0) return null; + + const burned = new Set(scan.burnedPolicyIds); + const affected = args.proxies.filter((proxy) => + burned.has(proxy.authTokenId.trim().toLowerCase()), + ); + const addresses = Array.from(new Set(affected.map((proxy) => proxy.proxyAddress))); + + for (const proxyAddress of addresses) { + let utxos: UTxO[]; + try { + utxos = await args.fetchProxyUtxos(proxyAddress); + } catch (error) { + console.error(`${args.context}: proxy balance lookup failed before burn`, error); + return "Unable to verify the proxy address is empty before burning its auth tokens"; + } + if (utxos.length > 0) { + console.error(`${args.context}: refusing burn, proxy address not empty`, { + proxyAddress, + utxos: utxos.length, + }); + return `Transaction burns the proxy auth tokens while the proxy address still holds ${utxos.length} UTxO(s); sweep them to the wallet before burning, or the funds are locked forever`; + } + } + return null; +} + +/** + * Pre-sign audit for any pending transaction of a wallet. If the wallet has proxies + * (active or not; a DB flag must not be able to switch this guard off while tokens can + * still exist on-chain), decodes `txCbor` and refuses it when + * - a proxy auth token would end up anywhere other than the wallet's own script + * address, or + * - it burns a proxy's auth tokens while that proxy address still holds UTxOs. + * Wallets without proxies are not affected (no extra work beyond one indexed lookup). + * + * Returns an error message to surface to the signer, or `null` when safe. Resolution + * failures fail closed only when the wallet actually has proxies to protect. + */ +export async function auditPendingTxForProxyWallet( + db: ProxyLookupDb, + args: { + wallet: DbWallet; + signerAddress: string; + txCbor: string; + context: string; + /** Test seam; defaults to the Blockfrost provider for the wallet's network. */ + fetchProxyUtxos?: ProxyUtxoFetcher; + }, +): Promise { + const proxies = await db.proxy.findMany({ + where: { walletId: args.wallet.id }, + select: { authTokenId: true, proxyAddress: true }, + }); + if (proxies.length === 0) return null; + + let walletAddress: string; + try { + walletAddress = resolveWalletScriptAddress( + args.wallet as DbWalletWithLegacy, + args.signerAddress, + ); + } catch (error) { + console.error(`${args.context}: wallet script address resolution failed`, error); + return "Unable to verify proxy auth-token outputs: wallet script address resolution failed"; + } + + const outputError = auditBuiltProxyTx({ + txCbor: args.txCbor, + authTokenIds: proxies.map((proxy) => proxy.authTokenId), + walletAddress, + context: args.context, + }); + if (outputError) return outputError; + + const fetchProxyUtxos = + args.fetchProxyUtxos ?? + ((proxyAddress: string) => + getProvider(addressToNetwork(walletAddress)).fetchAddressUTxOs(proxyAddress)); + + return auditAuthTokenBurn({ + txCbor: args.txCbor, + proxies, + fetchProxyUtxos, + context: args.context, + }); +} diff --git a/src/lib/zustand/proxy.ts b/src/lib/zustand/proxy.ts index a0c70be0..f73bd77c 100644 --- a/src/lib/zustand/proxy.ts +++ b/src/lib/zustand/proxy.ts @@ -178,10 +178,10 @@ export const useProxyStore = create()( }, { paramUtxo: JSON.parse(paramUtxo || '{}'), + proxyAddress, }, scriptCbor, ); - proxyContract.proxyAddress = proxyAddress; // Get DRep ID const drepId = proxyContract.getDrepId(); @@ -224,10 +224,10 @@ export const useProxyStore = create()( }, { paramUtxo: JSON.parse(paramUtxo || '{}'), + proxyAddress, }, scriptCbor, ); - proxyContract.proxyAddress = proxyAddress; // Check if DRep is registered before fetching delegators const drepStatus = await proxyContract.getDrepStatus(forceRefresh); @@ -321,10 +321,10 @@ export const useProxyStore = create()( }, { paramUtxo: JSON.parse(proxy.paramUtxo || '{}'), + proxyAddress: proxy.proxyAddress, }, scriptCbor, ); - proxyContract.proxyAddress = proxy.proxyAddress; // Fetch balance and DRep status first const [balance, drepStatus] = await Promise.allSettled([ diff --git a/src/pages/api/v1/README.md b/src/pages/api/v1/README.md index 11b25ddc..5c282fa9 100644 --- a/src/pages/api/v1/README.md +++ b/src/pages/api/v1/README.md @@ -154,6 +154,8 @@ Endpoints: - `POST /api/v1/proxySetup`: body `walletId`, `address`, `utxoRefs`, `collateralRef`, optional `initialProxyLovelace`, optional `description`; returns pending/submitted transaction plus setup metadata. When omitted, `initialProxyLovelace` defaults to the current minimal proxy output amount. - `POST /api/v1/proxySetupFinalize`: body `walletId`, `address`, `txHash`, `proxyAddress`, `authTokenId`, `paramUtxo`, optional `description`; creates or reactivates the confirmed proxy row after chain validation. - `POST /api/v1/proxySpend`: body `walletId`, `address`, `proxyId`, `outputs`, `utxoRefs`, `collateralRef`, optional `proxyUtxoRefs`, optional `description`; requires one multisig input containing the proxy auth token. If `proxyUtxoRefs` is omitted, the server fetches proxy-address UTxOs and selects enough to cover `outputs` plus a fee buffer. +- **Auth-token safety (all proxy routes)**: the on-chain proxy validator only checks that some output carries the auth token, so the server enforces off-chain that every auth token spent returns to the multisig wallet address (`src/lib/proxy/txBuilders.ts`; split 1 per output for spend/DRep/vote; the cleanup sweep returns them all in the swept-value output so the token UTxO stays funded for the burn), audits the finished CBOR before persisting (`src/lib/server/proxyAuthTokenAudit.ts`; a failure is a 500 because it means the builder misbehaved), and `signTransaction` / tRPC `transaction.createTransaction` / `importTransaction` / `updateTransaction` refuse to persist or witness any pending transaction of a proxy wallet that would move a token elsewhere (409 / `PRECONDITION_FAILED`). The audit covers every `Proxy` row of the wallet, active or not (deactivating or deleting a proxy in the app is a soft delete and does not relax the check), and it also refuses any transaction that burns the auth-token policy while the proxy address still holds UTxOs, because a burn is irreversible and anything left at the proxy would be locked forever. `proxySpend` additionally returns any auth token found among the proxy inputs to the wallet, so a token that leaked to the proxy address can be recovered. +- **Proxy address stake credential**: the proxy address returned by `proxySetup` uses the proxy script's own hash as a *script* stake credential, so withdrawals and delegation of proxy rewards go through the same validator as spends (they need an auth-token output). Proxies created before this change keep their stored `proxyAddress`; all proxy routes derive the stake credential from that stored address, so existing proxies keep working unchanged. - `POST /api/v1/proxyDRepCertificate`: body `walletId`, `address`, `proxyId`, `action` (`register`, `update`, `deregister`), `utxoRefs`, `collateralRef`, optional `description`; `anchorUrl` and `anchorJson` are required for `register` and `update`, and the server computes `hashDrepAnchor(anchorJson)` without fetching `anchorUrl`. - `POST /api/v1/proxyVote`: body `walletId`, `address`, `proxyId`, `votes`, `utxoRefs`, `collateralRef`, optional `description`; each vote has `proposalId` in `#` form and `voteKind` (`Yes`, `No`, `Abstain`). - `POST /api/v1/proxyCleanup`: body `walletId`, `address`, `proxyId`, `utxoRefs`, `collateralRef`, optional `proxyUtxoRefs`, optional `deactivateProxy`, optional `description`; returns cleanup metadata with phase `sweep` while proxy-address UTxOs remain, then phase `burn` once the proxy address is empty. When `proxyUtxoRefs` is provided for cleanup, it must include every currently visible proxy UTxO. @@ -337,7 +339,7 @@ Endpoints: - **Query Parameters**: - `walletId`: Wallet identifier - `address`: User address for authorization -- **Response**: Array of free UTxO objects +- **Response**: Array of free UTxO objects. Each carries `authToken: boolean` and `authTokenId: string | null`, set when the UTxO holds an auth token of any of the wallet's proxies (active or not), so callers can keep proxy permission tokens out of generic coin selection. - **Error Handling**: 400 (validation), 401 (auth), 403 (authorization), 404 (not found), 500 (server) ### Authentication Endpoints diff --git a/src/pages/api/v1/freeUtxos.ts b/src/pages/api/v1/freeUtxos.ts index 3586c6b2..d21e3158 100644 --- a/src/pages/api/v1/freeUtxos.ts +++ b/src/pages/api/v1/freeUtxos.ts @@ -184,11 +184,28 @@ export default async function handler( ), ); + // Flag UTxOs that carry a proxy auth token so callers doing coin selection + // can tell a plain funding UTxO from a proxy permission token. Every proxy + // row counts, active or not: the DB flag says nothing about whether tokens + // still exist on-chain. The builders conserve tokens regardless, but bots + // should still pass only the single token UTxO they intend to use per action. + const walletProxies = await db.proxy.findMany({ + where: { walletId }, + select: { authTokenId: true }, + }); + const authTokenIds = new Set(walletProxies.map((p) => p.authTokenId)); + const freeUtxosWithAuthTokenFlag = freeUtxos.map((utxo) => { + const authTokenId = + (utxo.output?.amount ?? []).find((asset) => authTokenIds.has(asset.unit))?.unit ?? + null; + return { ...utxo, authToken: authTokenId !== null, authTokenId }; + }); + 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, diff --git a/src/pages/api/v1/proxyCleanup.ts b/src/pages/api/v1/proxyCleanup.ts index 95a25ab9..40ce1e0f 100644 --- a/src/pages/api/v1/proxyCleanup.ts +++ b/src/pages/api/v1/proxyCleanup.ts @@ -27,7 +27,9 @@ import { buildProxyCleanupSweepTx, buildProxyCleanupTx, deriveProxyScripts, + proxyStakeCredentialFromAddress, } from "@/lib/server/proxyTxBuilders"; +import { auditBuiltProxyTx } from "@/lib/server/proxyAuthTokenAudit"; import type { DbWalletWithLegacy } from "@/types/wallet"; type MeshTxBuilderWithBody = Awaited> & { @@ -186,7 +188,8 @@ export default async function handler( } const network = address.includes("test") ? 0 : 1; - const scripts = deriveProxyScripts({ paramUtxo, network }); + const stake = proxyStakeCredentialFromAddress(proxy.proxyAddress); + const scripts = deriveProxyScripts({ paramUtxo, network, stake }); if (scripts.authTokenId !== proxy.authTokenId || scripts.proxyAddress !== proxy.proxyAddress) { return res.status(409).json({ error: "Stored proxy metadata does not match derived scripts" }); } @@ -252,6 +255,7 @@ export default async function handler( collateral: resolvedCollateral.collateral, walletAddress, multisigScriptCbor: walletRow.scriptCbor, + stake, }), }; } else { @@ -266,6 +270,7 @@ export default async function handler( walletAddress, authTokenId: proxy.authTokenId, multisigScriptCbor: walletRow.scriptCbor, + stake, }), }; } @@ -285,6 +290,17 @@ export default async function handler( }); } + // Sweep returns tokens to the wallet; burn leaves no token outputs. Both pass. + const auditError = auditBuiltProxyTx({ + txCbor, + authTokenIds: [proxy.authTokenId], + walletAddress, + context: "proxyCleanup", + }); + if (auditError) { + return res.status(500).json({ error: auditError }); + } + const description = typeof body.description === "string" && body.description.trim() ? body.description.trim() diff --git a/src/pages/api/v1/proxyDRepCertificate.ts b/src/pages/api/v1/proxyDRepCertificate.ts index 99dc97dc..e82c1f2c 100644 --- a/src/pages/api/v1/proxyDRepCertificate.ts +++ b/src/pages/api/v1/proxyDRepCertificate.ts @@ -24,7 +24,9 @@ import { getTxBuilder } from "@/utils/get-tx-builder"; import { buildProxyDRepCertificateTx, deriveProxyScripts, + proxyStakeCredentialFromAddress, } from "@/lib/server/proxyTxBuilders"; +import { auditBuiltProxyTx } from "@/lib/server/proxyAuthTokenAudit"; import type { DbWalletWithLegacy } from "@/types/wallet"; type ProxyDRepAction = "register" | "update" | "deregister"; @@ -149,7 +151,8 @@ export default async function handler( } const network = address.includes("test") ? 0 : 1; - const scripts = deriveProxyScripts({ paramUtxo, network }); + const stake = proxyStakeCredentialFromAddress(proxy.proxyAddress); + const scripts = deriveProxyScripts({ paramUtxo, network, stake }); if (scripts.authTokenId !== proxy.authTokenId || scripts.proxyAddress !== proxy.proxyAddress) { return res.status(409).json({ error: "Stored proxy metadata does not match derived scripts" }); } @@ -205,6 +208,7 @@ export default async function handler( anchorUrl, anchorJson, multisigScriptCbor: walletRow.scriptCbor, + stake, }); } catch (error) { return res.status(400).json({ @@ -222,6 +226,16 @@ export default async function handler( }); } + const auditError = auditBuiltProxyTx({ + txCbor, + authTokenIds: [proxy.authTokenId], + walletAddress, + context: "proxyDRepCertificate", + }); + if (auditError) { + return res.status(500).json({ error: auditError }); + } + const description = typeof body.description === "string" && body.description.trim() ? body.description.trim() diff --git a/src/pages/api/v1/proxyDRepInfo.ts b/src/pages/api/v1/proxyDRepInfo.ts index b816f4ec..e4fda3dc 100644 --- a/src/pages/api/v1/proxyDRepInfo.ts +++ b/src/pages/api/v1/proxyDRepInfo.ts @@ -5,7 +5,10 @@ import { verifyJwt, isBotJwt } from "@/lib/verifyJwt"; import { cors, addCorsCacheBustingHeaders } from "@/lib/cors"; import { applyRateLimit, applyBotRateLimit } from "@/lib/security/requestGuards"; import { authorizeProxyReadForV1, loadActiveProxyForWallet } from "@/lib/server/proxyAccess"; -import { deriveProxyScripts } from "@/lib/server/proxyTxBuilders"; +import { + deriveProxyScripts, + proxyStakeCredentialFromAddress, +} from "@/lib/server/proxyTxBuilders"; import type { UtxoRef } from "@/lib/server/proxyUtxos"; function getBlockfrostConfig(network: 0 | 1): { key: string; baseUrl: string } | null { @@ -101,7 +104,11 @@ export default async function handler( } const network: 0 | 1 = address.includes("test") ? 0 : 1; - const scripts = deriveProxyScripts({ paramUtxo, network }); + const scripts = deriveProxyScripts({ + paramUtxo, + network, + stake: proxyStakeCredentialFromAddress(proxy.proxyAddress), + }); if (scripts.authTokenId !== proxy.authTokenId || scripts.proxyAddress !== proxy.proxyAddress) { return res.status(409).json({ error: "Stored proxy metadata does not match derived scripts" }); } diff --git a/src/pages/api/v1/proxySetup.ts b/src/pages/api/v1/proxySetup.ts index 8de2f6fc..94a720ca 100644 --- a/src/pages/api/v1/proxySetup.ts +++ b/src/pages/api/v1/proxySetup.ts @@ -18,6 +18,7 @@ import { buildProxySetupTx, DEFAULT_PROXY_SETUP_LOVELACE, } from "@/lib/server/proxyTxBuilders"; +import { auditBuiltProxyTx } from "@/lib/server/proxyAuthTokenAudit"; import type { DbWalletWithLegacy } from "@/types/wallet"; type MeshTxBuilderWithBody = Awaited> & { @@ -185,6 +186,18 @@ export default async function handler( }); } + // All 10 freshly minted auth tokens must land at the wallet address (the builder + // emits them 1 per output; the audit only checks the address). + const auditError = auditBuiltProxyTx({ + txCbor, + authTokenIds: [setup.authTokenId], + walletAddress, + context: "proxySetup", + }); + if (auditError) { + return res.status(500).json({ error: auditError }); + } + const description = typeof body.description === "string" && body.description.trim() ? body.description.trim() diff --git a/src/pages/api/v1/proxySpend.ts b/src/pages/api/v1/proxySpend.ts index 824622f8..d12bfd19 100644 --- a/src/pages/api/v1/proxySpend.ts +++ b/src/pages/api/v1/proxySpend.ts @@ -24,7 +24,12 @@ import { createPendingMultisigTransaction } from "@/lib/server/createPendingMult 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"; +import { + buildProxySpendTx, + deriveProxyScripts, + proxyStakeCredentialFromAddress, +} from "@/lib/server/proxyTxBuilders"; +import { auditBuiltProxyTx } from "@/lib/server/proxyAuthTokenAudit"; import type { DbWalletWithLegacy } from "@/types/wallet"; type ProxyOutput = { address: string; unit: string; amount: string }; @@ -196,7 +201,8 @@ export default async function handler( } const network = address.includes("test") ? 0 : 1; - const scripts = deriveProxyScripts({ paramUtxo, network }); + const stake = proxyStakeCredentialFromAddress(proxy.proxyAddress); + const scripts = deriveProxyScripts({ paramUtxo, network, stake }); if (scripts.authTokenId !== proxy.authTokenId || scripts.proxyAddress !== proxy.proxyAddress) { return res.status(409).json({ error: "Stored proxy metadata does not match derived scripts" }); } @@ -270,6 +276,7 @@ export default async function handler( outputs, walletAddress, multisigScriptCbor: walletRow.scriptCbor, + stake, }); } catch (error) { return res.status(400).json({ @@ -287,6 +294,16 @@ export default async function handler( }); } + const auditError = auditBuiltProxyTx({ + txCbor, + authTokenIds: [proxy.authTokenId], + walletAddress, + context: "proxySpend", + }); + if (auditError) { + return res.status(500).json({ error: auditError }); + } + const description = typeof body.description === "string" && body.description.trim() ? body.description.trim() diff --git a/src/pages/api/v1/proxyVote.ts b/src/pages/api/v1/proxyVote.ts index 3a6d867b..43a3a9ae 100644 --- a/src/pages/api/v1/proxyVote.ts +++ b/src/pages/api/v1/proxyVote.ts @@ -24,8 +24,10 @@ import { getTxBuilder } from "@/utils/get-tx-builder"; import { buildProxyVoteTx, deriveProxyScripts, + proxyStakeCredentialFromAddress, type ProxyVoteInput, } from "@/lib/server/proxyTxBuilders"; +import { auditBuiltProxyTx } from "@/lib/server/proxyAuthTokenAudit"; import { parseProposalId } from "@/lib/governance"; import type { DbWalletWithLegacy } from "@/types/wallet"; @@ -172,7 +174,8 @@ export default async function handler( } const network = address.includes("test") ? 0 : 1; - const scripts = deriveProxyScripts({ paramUtxo, network }); + const stake = proxyStakeCredentialFromAddress(proxy.proxyAddress); + const scripts = deriveProxyScripts({ paramUtxo, network, stake }); if (scripts.authTokenId !== proxy.authTokenId || scripts.proxyAddress !== proxy.proxyAddress) { return res.status(409).json({ error: "Stored proxy metadata does not match derived scripts" }); } @@ -226,6 +229,7 @@ export default async function handler( walletAddress, votes, multisigScriptCbor: walletRow.scriptCbor, + stake, }); } catch (error) { return res.status(400).json({ @@ -243,6 +247,16 @@ export default async function handler( }); } + const auditError = auditBuiltProxyTx({ + txCbor, + authTokenIds: [proxy.authTokenId], + walletAddress, + context: "proxyVote", + }); + if (auditError) { + return res.status(500).json({ error: auditError }); + } + const description = typeof body.description === "string" && body.description.trim() ? body.description.trim() diff --git a/src/pages/api/v1/signTransaction.ts b/src/pages/api/v1/signTransaction.ts index 51fd426b..ec5be3a7 100644 --- a/src/pages/api/v1/signTransaction.ts +++ b/src/pages/api/v1/signTransaction.ts @@ -19,6 +19,7 @@ import { applyRateLimit, applyBotRateLimit, enforceBodySize } from "@/lib/securi import { getClientIP } from "@/lib/security/rateLimit"; import { getBotWalletAccess } from "@/lib/auth/botAccess"; import { enqueueThresholdReachedNotifications } from "@/lib/notifications/center"; +import { auditPendingTxForProxyWallet } from "@/lib/server/proxyAuthTokenAudit"; function coerceBoolean(value: unknown, fallback = false): boolean { if (typeof value === "boolean") return value; @@ -215,6 +216,18 @@ export default async function handler( return res.status(500).json({ error: "Stored transaction is missing txCbor" }); } + // Proxy safety: never add a witness to a transaction that would move one of this + // wallet's proxy auth tokens away from the wallet address (see authTokenAudit.ts). + const proxyAuditError = await auditPendingTxForProxyWallet(db, { + wallet, + signerAddress: address, + txCbor: storedTxHex, + context: "signTransaction", + }); + if (proxyAuditError) { + return res.status(409).json({ error: proxyAuditError }); + } + const signatureHex = normalizeHex(signature, "signature"); const keyHex = normalizeHex(key, "key"); diff --git a/src/server/api/routers/proxy.ts b/src/server/api/routers/proxy.ts index 2d187b38..e70bc724 100644 --- a/src/server/api/routers/proxy.ts +++ b/src/server/api/routers/proxy.ts @@ -413,6 +413,12 @@ export const proxyRouter = createTRPCRouter({ }); }), + /** + * Soft delete. The row is kept (flagged inactive) on purpose: the pre-sign + * auth-token audit keys off every Proxy row of a wallet, and a DB delete must + * not be able to switch that guard off while the 10 auth tokens can still exist + * on-chain. Proxies only truly end with the on-chain burn (proxyCleanup). + */ deleteProxy: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { @@ -422,10 +428,13 @@ export const proxyRouter = createTRPCRouter({ throw new TRPCError({ code: "NOT_FOUND", message: "Proxy not found" }); } await assertProxyManageAccess(ctx, proxy, sessionAddress); - return ctx.db.proxy.delete({ + return ctx.db.proxy.update({ where: { id: input.id, }, + data: { + isActive: false, + }, }); }), diff --git a/src/server/api/routers/transactions.ts b/src/server/api/routers/transactions.ts index 3821b4e4..32cf8e8a 100644 --- a/src/server/api/routers/transactions.ts +++ b/src/server/api/routers/transactions.ts @@ -15,6 +15,7 @@ import { enqueueThresholdReachedNotifications, } from "@/lib/notifications/center"; import { cancelPayoutsForTransaction, markPayoutsPaid } from "@/lib/task-payout/sync"; +import { auditPendingTxForProxyWallet } from "@/lib/server/proxyAuthTokenAudit"; function toHex(bytes: Uint8Array): string { return Buffer.from(bytes).toString("hex"); @@ -56,6 +57,20 @@ export const transactionRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const wallet = await assertWalletAccess(ctx, input.walletId); const sessionAddress = ctx.session?.user?.id ?? ctx.sessionAddress ?? null; + + // Proxy safety: never persist a pending transaction that would move one of + // this wallet's proxy auth tokens away from the wallet address (or burn them + // while the proxy still holds funds). Co-signers must never see such a tx. + const proxyAuditError = await auditPendingTxForProxyWallet(ctx.db, { + wallet, + signerAddress: sessionAddress ?? input.signedAddresses[0] ?? "", + txCbor: input.txCbor, + context: "transaction.createTransaction", + }); + if (proxyAuditError) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: proxyAuditError }); + } + const data = { walletId: input.walletId, txJson: input.txJson, @@ -162,6 +177,25 @@ export const transactionRouter = createTRPCRouter({ } const wallet = await assertWalletAccess(ctx, tx.walletId); const sessionAddress = ctx.session?.user?.id ?? ctx.sessionAddress ?? null; + + // Proxy safety (UI signing path): when a signature is being added, refuse to + // record it if the transaction would move one of this wallet's proxy auth + // tokens away from the wallet address. Mirrors v1 signTransaction. + const addsSignature = input.signedAddresses.some( + (addr) => !tx.signedAddresses.includes(addr), + ); + if (addsSignature) { + const proxyAuditError = await auditPendingTxForProxyWallet(ctx.db, { + wallet, + signerAddress: sessionAddress ?? input.signedAddresses[0] ?? "", + txCbor: input.txCbor, + context: "transaction.updateTransaction", + }); + if (proxyAuditError) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: proxyAuditError }); + } + } + const updated = await ctx.db.transaction.update({ where: { id: input.transactionId, @@ -293,6 +327,26 @@ export const transactionRouter = createTRPCRouter({ }); }), + /** + * Pre-signature proxy audit for the browser. The UI calls this before asking the + * connected wallet for a witness, because the final signer submits to the chain + * before `updateTransaction` runs and a server-side refusal would come too late. + * Returns `{ error: null }` when the transaction is safe to sign. + */ + preSignProxyAudit: protectedProcedure + .input(z.object({ walletId: z.string(), txCbor: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const wallet = await assertWalletAccess(ctx, input.walletId); + const sessionAddress = ctx.session?.user?.id ?? ctx.sessionAddress ?? ""; + const error = await auditPendingTxForProxyWallet(ctx.db, { + wallet, + signerAddress: sessionAddress, + txCbor: input.txCbor, + context: "transaction.preSignProxyAudit", + }); + return { error }; + }), + importTransaction: protectedProcedure .input( z.object({ @@ -627,6 +681,19 @@ export const transactionRouter = createTRPCRouter({ // Create pending transaction const sessionAddress = ctx.session?.user?.id ?? ctx.sessionAddress ?? null; + + // Proxy safety: externally built transactions get the same auth-token audit + // as everything else before co-signers can see them. + const proxyAuditError = await auditPendingTxForProxyWallet(ctx.db, { + wallet, + signerAddress: sessionAddress ?? signedAddresses[0] ?? "", + txCbor: txHex, + context: "transaction.importTransaction", + }); + if (proxyAuditError) { + throw new TRPCError({ code: "PRECONDITION_FAILED", message: proxyAuditError }); + } + const created = await ctx.db.transaction.create({ data: { walletId: input.walletId, diff --git a/src/utils/preSignProxyAudit.ts b/src/utils/preSignProxyAudit.ts new file mode 100644 index 00000000..a6c0d122 --- /dev/null +++ b/src/utils/preSignProxyAudit.ts @@ -0,0 +1,32 @@ +/** + * Browser-side gate in front of `wallet.signTx` for multisig transactions. + * + * The server audits proxy auth-token movements in `createTransaction`, + * `updateTransaction` and v1 `signTransaction`, but the final signer in the UI + * broadcasts to the chain *before* recording the signature, so a server refusal + * at that point would be too late. Asking the server first closes that window. + * + * Fails closed: if the audit cannot be reached, the caller must not sign. + */ +export type PreSignProxyAuditOutcome = + | { ok: true } + | { ok: false; message: string }; + +export const PRE_SIGN_AUDIT_UNAVAILABLE = + "Could not verify proxy auth-token safety for this transaction. Check your connection and try again; the transaction was not signed."; + +export async function runPreSignProxyAudit(args: { + fetchAudit: (input: { walletId: string; txCbor: string }) => Promise<{ error: string | null }>; + walletId: string; + txCbor: string; +}): Promise { + let result: { error: string | null }; + try { + result = await args.fetchAudit({ walletId: args.walletId, txCbor: args.txCbor }); + } catch (error) { + console.error("preSignProxyAudit request failed", error); + return { ok: false, message: PRE_SIGN_AUDIT_UNAVAILABLE }; + } + if (result.error) return { ok: false, message: result.error }; + return { ok: true }; +} diff --git a/src/utils/swagger.ts b/src/utils/swagger.ts index 978f7432..0ee8a2c2 100644 --- a/src/utils/swagger.ts +++ b/src/utils/swagger.ts @@ -265,6 +265,17 @@ This API uses **Bearer Token** authentication (JWT). }, }, }, + authToken: { + type: "boolean", + description: + "True when this UTxO carries an auth token of any of the wallet's proxies (active or not). Pass only the single auth-token UTxO you intend to use in utxoRefs for proxySpend, proxyDRepCertificate, and proxyVote; the server returns every spent auth token to the wallet address regardless.", + }, + authTokenId: { + type: "string", + nullable: true, + description: + "Policy id of the proxy auth token carried by this UTxO, or null.", + }, }, }, }, @@ -523,7 +534,7 @@ This API uses **Bearer Token** authentication (JWT). tags: ["V1", "Bot"], summary: "Build a proxy setup transaction", description: - "Builds a Plutus proxy setup transaction, persists it through the multisig pending transaction flow with no initial signed addresses, and returns derived setup metadata. Bots need multisig:sign and cosigner access. Proxy rows are not created until POST /api/v1/proxySetupFinalize validates confirmed chain state.", + "Builds a Plutus proxy setup transaction, persists it through the multisig pending transaction flow with no initial signed addresses, and returns derived setup metadata. The returned proxyAddress uses the proxy script's own hash as a script stake credential, so reward withdrawals and delegation are governed by the same validator as spends. Bots need multisig:sign and cosigner access. Proxy rows are not created until POST /api/v1/proxySetupFinalize validates confirmed chain state.", requestBody: { required: true, content: { @@ -632,7 +643,7 @@ This API uses **Bearer Token** authentication (JWT). tags: ["V1", "Bot"], summary: "Build a proxy spend transaction", description: - "Builds a proxy script spend transaction and persists it through the multisig pending transaction flow with no initial signed addresses. Requires an auth-token UTxO at the multisig wallet address. If proxyUtxoRefs is omitted, the server selects enough proxy-address UTxOs for the requested outputs plus fee buffer. Bots need multisig:sign and cosigner access.", + "Builds a proxy script spend transaction and persists it through the multisig pending transaction flow with no initial signed addresses. Requires an auth-token UTxO at the multisig wallet address; pass only the one you intend to use in utxoRefs (see freeUtxos authToken flag). Every auth token spent is returned to the wallet address (1 per output), including any token found among the proxy-address inputs, and the finished transaction is audited so no token can reach the proxy address as change. If proxyUtxoRefs is omitted, the server selects enough proxy-address UTxOs for the requested outputs plus fee buffer. Bots need multisig:sign and cosigner access.", requestBody: { required: true, content: { @@ -765,7 +776,7 @@ This API uses **Bearer Token** authentication (JWT). tags: ["V1", "Bot"], summary: "Build a proxy cleanup transaction", description: - "Builds the next safe cleanup transaction through the multisig pending transaction flow with no initial signed addresses. If the proxy address still has UTxOs, the transaction sweeps them back to the multisig wallet while preserving an auth token. Once the proxy address is empty, the transaction burns all auth tokens. Bots need multisig:sign and cosigner access. The Proxy row is deactivated only after POST /api/v1/proxyCleanupFinalize validates the confirmed burn transaction hash and current chain state.", + "Builds the next safe cleanup transaction through the multisig pending transaction flow with no initial signed addresses. If the proxy address still has UTxOs, the transaction sweeps them back to the multisig wallet in a single output that also carries every spent auth token. Once the proxy address is empty, the transaction burns all auth tokens; the server-side audit refuses to persist or sign a burn while the proxy address still holds any UTxO, because a burn is irreversible. Pass every auth-token UTxO in utxoRefs plus at least one ADA-only wallet UTxO: token UTxOs may hold only min-ADA, and the burn has no other inputs to pay the fee and a min-ADA change output from. Bots need multisig:sign and cosigner access. The Proxy row is deactivated only after POST /api/v1/proxyCleanupFinalize validates the confirmed burn transaction hash and current chain state.", requestBody: { required: true, content: { @@ -976,7 +987,7 @@ This API uses **Bearer Token** authentication (JWT). 404: { description: "Wallet or transaction not found" }, 409: { description: - "Transaction already finalized or conflicting update detected", + "Transaction already finalized, conflicting update detected, or (for wallets that have or had a proxy) the transaction would move a proxy auth token away from the wallet address, or would burn the auth tokens while the proxy address still holds UTxOs, and is refused before any witness is added", }, 502: { description: