From d80b69f211781ebc7b0e01578f73d2d48823f928 Mon Sep 17 00:00:00 2001 From: Kyle Cox Date: Thu, 15 Oct 2026 11:51:59 +0000 Subject: [PATCH] fix(evolution): scope reference UTxOs per transaction in batch signing (#483) --- .../batch-signing-scope-reference-utxos.md | 5 + .../src/sdk/client/internal/Signing.ts | 29 ++++- .../test/Signing.BatchReferenceUtxos.test.ts | 115 ++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 .changeset/batch-signing-scope-reference-utxos.md create mode 100644 packages/evolution/test/Signing.BatchReferenceUtxos.test.ts diff --git a/.changeset/batch-signing-scope-reference-utxos.md b/.changeset/batch-signing-scope-reference-utxos.md new file mode 100644 index 00000000..497a145f --- /dev/null +++ b/.changeset/batch-signing-scope-reference-utxos.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +Scope reference UTxOs per transaction when signing (#483). Batch signing (`signTxsWithAutoFetch`) fetched the union of every transaction's reference inputs and passed the combined list to each transaction's signing call, so a native script referenced by one transaction could add an unneeded witness to an unrelated transaction in the batch. The batch path now signs each transaction with only its own reference UTxOs (the combined fetch is kept), and required-signer computation itself only counts reference UTxOs the transaction actually references, matching the single-transaction path. diff --git a/packages/evolution/src/sdk/client/internal/Signing.ts b/packages/evolution/src/sdk/client/internal/Signing.ts index 9c213d2f..3398b5b5 100644 --- a/packages/evolution/src/sdk/client/internal/Signing.ts +++ b/packages/evolution/src/sdk/client/internal/Signing.ts @@ -70,7 +70,18 @@ const computeRequiredKeyHashesSync = (params: { } if (params.referenceUtxos) { + // Only reference UTxOs this transaction actually references can require a + // signer for it. Callers may hand over a wider set (batch signing fetches + // the union of every transaction's reference inputs); counting scripts + // from UTxOs belonging to other transactions would add witnesses this + // transaction never asked for (#483). + const referenced = new Set( + (params.tx.body.referenceInputs ?? []).map((ref) => `${TransactionHash.toHex(ref.transactionId)}#${ref.index}`) + ) for (const utxo of params.referenceUtxos) { + if (!referenced.has(CoreUTxO.toOutRefString(utxo))) { + continue + } if (utxo.scriptRef && utxo.scriptRef._tag === "NativeScript") { const keyHashes = extractKeyHashesFromNativeScript(utxo.scriptRef.script) for (const keyHash of keyHashes) { @@ -219,6 +230,7 @@ export const signTxsWithAutoFetch = ( // Collect all reference inputs across all transactions and fetch them once const seen = new Set() const allRefInputs: Array = [] + const decodedTxs: Array = [] for (const txOrHex of txs) { const tx = typeof txOrHex === "string" @@ -228,6 +240,7 @@ export const signTxsWithAutoFetch = ( ) ) : txOrHex + decodedTxs.push(tx) if (tx.body.referenceInputs) { for (const ref of tx.body.referenceInputs) { const key = `${ref.transactionId}#${ref.index}` @@ -252,7 +265,21 @@ export const signTxsWithAutoFetch = ( ) } - return yield* wallet.effect.signTxs(txs, { ...context, referenceUtxos }) + // Sign each transaction with only its own reference UTxOs, matching the + // single-transaction path: the combined fetch above is an optimisation, + // not a licence for one transaction's reference scripts to add witnesses + // to another transaction in the batch (#483). + return yield* Effect.all( + txs.map((txOrHex, index) => { + const referenced = new Set( + (decodedTxs[index].body.referenceInputs ?? []).map( + (ref) => `${TransactionHash.toHex(ref.transactionId)}#${ref.index}` + ) + ) + const scopedReferenceUtxos = referenceUtxos.filter((utxo) => referenced.has(CoreUTxO.toOutRefString(utxo))) + return wallet.effect.signTx(txOrHex, { ...context, referenceUtxos: scopedReferenceUtxos }) + }) + ) }) export const signWithAutoFetch = ( diff --git a/packages/evolution/test/Signing.BatchReferenceUtxos.test.ts b/packages/evolution/test/Signing.BatchReferenceUtxos.test.ts new file mode 100644 index 00000000..2cf8eb4a --- /dev/null +++ b/packages/evolution/test/Signing.BatchReferenceUtxos.test.ts @@ -0,0 +1,115 @@ +import { Effect } from "effect" +import { describe, expect, it } from "vitest" + +import * as CoreAddress from "../src/Address.js" +import * as CoreAssets from "../src/Assets.js" +import * as KeyHash from "../src/KeyHash.js" +import * as NativeScripts from "../src/NativeScripts.js" +import * as PrivateKey from "../src/PrivateKey.js" +import { preprod } from "../src/sdk/client/Chain.js" +import { signTxsWithAutoFetch, signWithAutoFetch } from "../src/sdk/client/internal/Signing.js" +import { seedWallet } from "../src/sdk/client/internal/Wallets.js" +import type * as Provider from "../src/sdk/provider/Provider.js" +import { keysFromSeed } from "../src/sdk/wallet/Derivation.js" +import * as Transaction from "../src/Transaction.js" +import * as TransactionBody from "../src/TransactionBody.js" +import * as TransactionHash from "../src/TransactionHash.js" +import * as TransactionInput from "../src/TransactionInput.js" +import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js" +import * as CoreUTxO from "../src/UTxO.js" + +const mnemonic = + "zebra short room flavor rival capital fortune hip profit trust melody office depend adapt visa cycle february link tornado whisper physical kiwi film voyage" + +const keys = keysFromSeed(mnemonic) +const paymentKey = PrivateKey.fromBech32(keys.paymentKey) +const paymentKh = KeyHash.fromPrivateKey(paymentKey) + +// A reference UTxO carrying a native script that names the wallet's payment +// key hash: any transaction that (wrongly) counts it as a required signer +// source will produce a vkey witness from this wallet. +const scriptTxId = TransactionHash.fromHex("aa".repeat(32)) +const scriptUtxo = new CoreUTxO.UTxO({ + transactionId: scriptTxId, + index: 0n, + address: new CoreAddress.Address({ networkId: 0, paymentCredential: KeyHash.fromHex("00".repeat(28)) }), + assets: CoreAssets.fromLovelace(2_000_000n), + scriptRef: NativeScripts.makeScriptPubKey(paymentKh.hash) +}) +const scriptRefInput = new TransactionInput.TransactionInput({ transactionId: scriptTxId, index: 0n }) + +const plainTx = (): Transaction.Transaction => + new Transaction.Transaction({ + body: new TransactionBody.TransactionBody({ inputs: [], outputs: [], fee: 200_000n }), + witnessSet: TransactionWitnessSet.empty(), + isValid: true, + auxiliaryData: null + }) + +const referencingTx = (): Transaction.Transaction => + new Transaction.Transaction({ + body: new TransactionBody.TransactionBody({ + inputs: [], + outputs: [], + fee: 200_000n, + referenceInputs: [scriptRefInput] + }), + witnessSet: TransactionWitnessSet.empty(), + isValid: true, + auxiliaryData: null + }) + +// Provider stub: resolves any requested out ref to the script UTxO above. +const stubProvider = { + effect: { + getUtxosByOutRef: (inputs: ReadonlyArray) => + Effect.succeed(inputs.map(() => scriptUtxo)) + } +} as unknown as Provider.Provider + +const witnessKeyHashHexes = (ws: TransactionWitnessSet.TransactionWitnessSet): Array => + (ws.vkeyWitnesses ?? []).map((w) => KeyHash.toHex(KeyHash.fromVKey(w.vkey))) + +describe("batch signing scopes reference UTxOs per transaction (#483)", () => { + it("a transaction that does not reference the script UTxO is not signed for it", async () => { + const wallet = seedWallet({ mnemonic })(preprod) + + const results = await Effect.runPromise(signTxsWithAutoFetch(stubProvider, wallet, [plainTx(), referencingTx()])) + + expect(witnessKeyHashHexes(results[0])).toEqual([]) + expect(witnessKeyHashHexes(results[1])).toEqual([KeyHash.toHex(paymentKh)]) + }) + + it("scoping holds regardless of batch order", async () => { + const wallet = seedWallet({ mnemonic })(preprod) + + const results = await Effect.runPromise(signTxsWithAutoFetch(stubProvider, wallet, [referencingTx(), plainTx()])) + + expect(witnessKeyHashHexes(results[0])).toEqual([KeyHash.toHex(paymentKh)]) + expect(witnessKeyHashHexes(results[1])).toEqual([]) + }) + + it("signTx ignores reference UTxOs the transaction does not reference (explicit context)", async () => { + const wallet = seedWallet({ mnemonic })(preprod) + + const witnessSet = await wallet.signTx(plainTx(), { referenceUtxos: [scriptUtxo] }) + + expect(witnessKeyHashHexes(witnessSet)).toEqual([]) + }) + + it("signTx signs for a reference UTxO the transaction does reference (control)", async () => { + const wallet = seedWallet({ mnemonic })(preprod) + + const witnessSet = await wallet.signTx(referencingTx(), { referenceUtxos: [scriptUtxo] }) + + expect(witnessKeyHashHexes(witnessSet)).toEqual([KeyHash.toHex(paymentKh)]) + }) + + it("single-transaction auto-fetch still signs for its own reference script (control)", async () => { + const wallet = seedWallet({ mnemonic })(preprod) + + const witnessSet = await Effect.runPromise(signWithAutoFetch(stubProvider, wallet, referencingTx())) + + expect(witnessKeyHashHexes(witnessSet)).toEqual([KeyHash.toHex(paymentKh)]) + }) +})