diff --git a/packages/evolution/src/sdk/builders/internal/txBuilder.ts b/packages/evolution/src/sdk/builders/internal/txBuilder.ts index 0ed060df..5dd006ad 100644 --- a/packages/evolution/src/sdk/builders/internal/txBuilder.ts +++ b/packages/evolution/src/sdk/builders/internal/txBuilder.ts @@ -1211,9 +1211,6 @@ export const buildFakeWitnessSet = ( switch (script._tag) { case "NativeScript": { nativeScripts.push(script) - // Count required signers for this native script and add fake witnesses - const requiredSigners = addNativeScriptWitnesses(script) - yield* Effect.logDebug(`[buildFakeWitnessSet] Native script requires ${requiredSigners} signers`) break } case "PlutusV1": @@ -1228,11 +1225,67 @@ export const buildFakeWitnessSet = ( } } - // Also count required signers from scripts carried by reference or spent inputs - for (const utxo of [...state.referenceInputs, ...inputUtxos]) { - if (utxo.scriptRef && utxo.scriptRef._tag === "NativeScript") { - const requiredSigners = addNativeScriptWitnesses(utxo.scriptRef) - yield* Effect.logDebug(`[buildFakeWitnessSet] Input-carried native script requires ${requiredSigners} signers`) + // Count native-script signers only for scripts required by an actual transaction purpose. + const requiredScriptHashes = new Set() + for (const utxo of state.selectedUtxos) { + if (utxo.address.paymentCredential._tag === "ScriptHash") { + requiredScriptHashes.add(ScriptHash.toHex(utxo.address.paymentCredential)) + } + } + if (state.mint) { + for (const policyId of state.mint.map.keys()) { + requiredScriptHashes.add(PolicyId.toHex(policyId)) + } + } + for (const certificate of state.certificates) { + let scriptHashHex = certScriptHashHex(certificate) + if (scriptHashHex === undefined) { + switch (certificate._tag) { + case "StakeRegistration": + case "StakeDeregistration": + case "StakeDelegation": + case "RegCert": + case "UnregCert": + case "VoteDelegCert": + case "StakeVoteDelegCert": + case "StakeRegDelegCert": + case "VoteRegDelegCert": + case "StakeVoteRegDelegCert": + if (certificate.stakeCredential._tag === "ScriptHash") { + scriptHashHex = ScriptHash.toHex(certificate.stakeCredential) + } + break + } + } + if (scriptHashHex !== undefined) requiredScriptHashes.add(scriptHashHex) + } + for (const [rewardAccount] of state.withdrawals) { + if (rewardAccount.stakeCredential._tag === "ScriptHash") { + requiredScriptHashes.add(ScriptHash.toHex(rewardAccount.stakeCredential)) + } + } + for (const voter of state.votingProcedures?.procedures.keys() ?? []) { + const scriptHashHex = voterScriptHashHex(voter) + if (scriptHashHex !== undefined) requiredScriptHashes.add(scriptHashHex) + } + for (const procedure of state.proposalProcedures?.procedures ?? []) { + const scriptHashHex = proposalPolicyHashHex(procedure.governanceAction) + if (scriptHashHex !== undefined) requiredScriptHashes.add(scriptHashHex) + } + + const isNativeScript = makeIsNativeScript(state) + for (const scriptHashHex of requiredScriptHashes) { + if (isNativeScript(scriptHashHex) !== true) continue + + const script = state.scripts.get(scriptHashHex) ?? + [...state.referenceInputs, ...state.selectedUtxos] + .map((utxo) => utxo.scriptRef) + .find((script) => + script?._tag === "NativeScript" && ScriptHash.toHex(ScriptHash.fromScript(script)) === scriptHashHex + ) + if (script?._tag === "NativeScript") { + const requiredSigners = addNativeScriptWitnesses(script) + yield* Effect.logDebug(`[buildFakeWitnessSet] Required native script requires ${requiredSigners} signers`) } } diff --git a/packages/evolution/test/TxBuilder.NativeScriptSignerEstimation.test.ts b/packages/evolution/test/TxBuilder.NativeScriptSignerEstimation.test.ts new file mode 100644 index 00000000..3d05699a --- /dev/null +++ b/packages/evolution/test/TxBuilder.NativeScriptSignerEstimation.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Ref } from "effect" + +import * as CoreAddress from "../src/Address.js" +import * as CoreAssets from "../src/Assets.js" +import * as Certificate from "../src/Certificate.js" +import * as NativeScripts from "../src/NativeScripts.js" +import * as ScriptHash from "../src/ScriptHash.js" +import * as BuilderState from "../src/sdk/builders/internal/state.js" +import { buildFakeWitnessSet } from "../src/sdk/builders/internal/txBuilder.js" +import { makeTxBuilder, type TxBuilderConfig, type TxBuilderState,TxContext } from "../src/sdk/builders/TransactionBuilder.js" +import { mainnet } from "../src/sdk/client/index.js" +import * as TransactionHash from "../src/TransactionHash.js" +import * as CoreUTxO from "../src/UTxO.js" +import { createCoreTestUtxo } from "./utils/utxo-helpers.js" + +const PROTOCOL_PARAMS = { + minFeeCoefficient: 44n, + minFeeConstant: 155_381n, + coinsPerUtxoByte: 4_310n, + maxTxSize: 16_384 +} + +const CHANGE_ADDRESS = + "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs68faae" + +const baseConfig = { chain: mainnet } satisfies TxBuilderConfig + +const unusedNativeScript = NativeScripts.makeScriptPubKey(new Uint8Array(28).fill(0xcc)) + +const scriptCarrier = (hash: string, scriptRef = unusedNativeScript) => + new CoreUTxO.UTxO({ + transactionId: TransactionHash.fromHex(hash), + index: 0n, + address: CoreAddress.fromBech32(CHANGE_ADDRESS), + assets: CoreAssets.fromLovelace(20_000_000n), + scriptRef + }) + +const buildOptions = { + changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS), + availableUtxos: [ + createCoreTestUtxo({ + transactionId: "a".repeat(64), + index: 0, + address: CHANGE_ADDRESS, + lovelace: 100_000_000n + }) + ], + protocolParameters: PROTOCOL_PARAMS +} + +describe("TxBuilder native script signer estimation (#596)", () => { + it("does not count signers for an unrelated native script carried by a spent input", async () => { + const builder = await makeTxBuilder(baseConfig) + .collectFrom({ inputs: [scriptCarrier("b".repeat(64))] }) + .build(buildOptions) + + const fakeTx = await builder.toTransactionWithFakeWitnesses() + expect(fakeTx.witnessSet.vkeyWitnesses?.length ?? 0).toBe(1) + }) + + it("does not count signers for an unrelated native script carried by a reference input", async () => { + const builder = await makeTxBuilder(baseConfig) + .readFrom({ referenceInputs: [scriptCarrier("c".repeat(64))] }) + .build(buildOptions) + + const fakeTx = await builder.toTransactionWithFakeWitnesses() + expect(fakeTx.witnessSet.vkeyWitnesses?.length ?? 0).toBe(1) + }) + + it("counts signers for a native-script stake registration credential", async () => { + const requiredNativeScript = NativeScripts.makeScriptPubKey(new Uint8Array(28).fill(0xdd)) + const scriptHash = ScriptHash.fromScript(requiredNativeScript) + const state: TxBuilderState = { + ...BuilderState.makeInitialTxBuilderState(), + certificates: [new Certificate.StakeRegistration({ stakeCredential: scriptHash })], + referenceInputs: [scriptCarrier("e".repeat(64), requiredNativeScript)] + } + const stateRef = await Effect.runPromise(Ref.make(state)) + const input = buildOptions.availableUtxos[0]! + + const fakeWitnessSet = await Effect.runPromise( + buildFakeWitnessSet([input]).pipe(Effect.provideService(TxContext, stateRef)) + ) + + // One payment-key witness for the input and one signer for the stake credential. + expect(fakeWitnessSet.vkeyWitnesses?.length ?? 0).toBe(2) + }) +}) diff --git a/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts b/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts index 27ce161e..a20c9263 100644 --- a/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts +++ b/packages/evolution/test/TxBuilder.ProposeGuardrail.test.ts @@ -394,7 +394,7 @@ describe("TxBuilder propose with guardrail script: combinations", () => { // The wallet key and the guardrail's own signer both need a vkey witness const fakeTx = await signBuilder.toTransactionWithFakeWitnesses() - expect(fakeTx.witnessSet.vkeyWitnesses?.length ?? 0).toBeGreaterThanOrEqual(2) + expect(fakeTx.witnessSet.vkeyWitnesses?.length ?? 0).toBe(2) }) it("drops a supplied redeemer", async () => {