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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-native-script-spend-redeemer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evolution-sdk/evolution": patch
---

Fix collectFrom storing redeemers for native script UTxOs. When a redeemer was passed to collectFrom for a native script input, it was incorrectly treated as a Plutus spend, causing "associated script witness is missing" errors during evaluation.
1 change: 1 addition & 0 deletions packages/evolution/src/sdk/builders/internal/txBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ export const assembleTransaction = (
}

yield* Effect.logDebug(`[Assembly] WitnessSet populated:`)
yield* Effect.logDebug(` - Native scripts: ${nativeScripts.length}`)
yield* Effect.logDebug(` - PlutusV1 scripts: ${plutusV1Scripts.length}`)
yield* Effect.logDebug(` - PlutusV2 scripts: ${plutusV2Scripts.length}`)
yield* Effect.logDebug(` - PlutusV3 scripts: ${plutusV3Scripts.length}`)
Expand Down
9 changes: 5 additions & 4 deletions packages/evolution/src/sdk/builders/operations/Collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,15 @@ export const createCollectFromProgram = (
let newRedeemers = state.redeemers
let newDeferredRedeemers = state.deferredRedeemers

// 5. Track redeemer information if spending from scripts
if (params.redeemer && scriptUtxos.length > 0) {
// 5. Track redeemer information if spending from Plutus scripts
// Native scripts are validated by signatures, not redeemers
if (params.redeemer && plutusScriptUtxos.length > 0) {
const deferred = RedeemerBuilder.toDeferredRedeemer(params.redeemer)

if (deferred._tag === "static") {
// Static mode: store resolved data immediately
newRedeemers = new Map(state.redeemers)
scriptUtxos.forEach((utxo) => {
plutusScriptUtxos.forEach((utxo) => {
const inputKey = UTxO.toOutRefString(utxo)
newRedeemers.set(inputKey, {
tag: "spend",
Expand All @@ -115,7 +116,7 @@ export const createCollectFromProgram = (
} else {
// Self or Batch mode: store deferred for resolution after coin selection
newDeferredRedeemers = new Map(state.deferredRedeemers)
scriptUtxos.forEach((utxo) => {
plutusScriptUtxos.forEach((utxo) => {
const inputKey = UTxO.toOutRefString(utxo)
newDeferredRedeemers.set(inputKey, {
tag: "spend",
Expand Down
90 changes: 90 additions & 0 deletions packages/evolution/test/TxBuilder.NativeScriptSpend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from "@effect/vitest"

import * as CoreAddress from "../src/Address.js"
import * as CoreAssets from "../src/Assets.js"
import * as Data from "../src/Data.js"
import * as NativeScripts from "../src/NativeScripts.js"
import * as ScriptHash from "../src/ScriptHash.js"
import type { TxBuilderConfig } from "../src/sdk/builders/TransactionBuilder.js"
import { makeTxBuilder } from "../src/sdk/builders/TransactionBuilder.js"
import { mainnet } from "../src/sdk/client/index.js"
import * as CoreTransactionHash 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: TxBuilderConfig = { chain: mainnet }

describe("TxBuilder NativeScript Spend (#339)", () => {
it("should not create redeemers when spending from a native script address with a redeemer passed", async () => {
// Create a native script
const dummyKeyHash = new Uint8Array(28).fill(0xaa)
const nativeScript = NativeScripts.makeScriptPubKey(dummyKeyHash)
const scriptHash = ScriptHash.fromScript(nativeScript)

// Create a script address using the native script hash
const scriptAddress = new CoreAddress.Address({
networkId: 0,
paymentCredential: scriptHash,
stakingCredential: undefined
})

// Create a UTxO at the script address
const scriptUtxo = new CoreUTxO.UTxO({
transactionId: CoreTransactionHash.fromHex("b".repeat(64)),
index: 0n,
address: scriptAddress,
assets: CoreAssets.fromLovelace(10_000_000n)
})

// Wallet UTxO for fees/change
const walletUtxo = createCoreTestUtxo({
transactionId: "a".repeat(64),
index: 0n,
address: CHANGE_ADDRESS,
lovelace: 100_000_000n
})

// Bug reproduction: user passes a redeemer to collectFrom even though
// the UTxO is at a native script address. The builder should ignore the
// redeemer for native script UTxOs instead of creating a spend redeemer.
const signBuilder = await makeTxBuilder(baseConfig)
.attachScript({ script: nativeScript })
.collectFrom({
inputs: [scriptUtxo],
redeemer: new Data.Constr({ index: 0n, fields: [] })
})
.payToAddress({
address: CoreAddress.fromBech32(CHANGE_ADDRESS),
assets: CoreAssets.fromLovelace(2_000_000n)
})
.build({
changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS),
availableUtxos: [walletUtxo],
protocolParameters: PROTOCOL_PARAMS
})

const tx = await signBuilder.toTransaction()

// The native script must be in the witness set
expect(tx.witnessSet.nativeScripts.length).toBeGreaterThan(0)

// There must be NO redeemers (native scripts use signatures, not redeemers)
expect(tx.witnessSet.redeemers).toBeUndefined()

// There must be NO scriptDataHash (only needed for Plutus scripts)
expect(tx.body.scriptDataHash).toBeUndefined()

// There must be NO collateral (only needed for Plutus scripts)
expect(tx.body.collateralInputs).toBeUndefined()
})
})
Loading