Skip to content

Commit d25d927

Browse files
committed
fix: add reference script size to base fee calculation for native scripts
- Include reference script bytes in transaction size for base fee - Apply tiered pricing separately (15/25/100 lovelace per byte) - Fix native script detection in reference inputs for redeemer validation - Use Equal.equals() for KeyHash deduplication in AddSigner
1 parent 240c5de commit d25d927

File tree

7 files changed

+204
-34
lines changed

7 files changed

+204
-34
lines changed

packages/evolution-devnet/test/TxBuilder.NativeScript.test.ts

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,7 @@ describe("TxBuilder NativeScript (Devnet Submit)", () => {
324324

325325
const submitBuilder = await signBuilder.sign()
326326
const txHash = await submitBuilder.submit()
327-
328327
expect(txHash.length).toBe(64)
329-
330328
const confirmed = await client.awaitTx(txHash, 1000)
331329
expect(confirmed).toBe(true)
332330
})
@@ -558,4 +556,105 @@ describe("TxBuilder NativeScript (Devnet Submit)", () => {
558556
const mintConfirmed = await client1.awaitTx(mintTxHash, 1000)
559557
expect(mintConfirmed).toBe(true)
560558
})
559+
560+
it("should spend from script address using native script as reference input", { timeout: 60_000 }, async () => {
561+
const client1 = createTestClient(0)
562+
const client2 = createTestClient(1)
563+
564+
const address1 = await client1.address()
565+
const address2 = await client2.address()
566+
567+
const credential1 = address1.paymentCredential
568+
const credential2 = address2.paymentCredential
569+
570+
if (credential1._tag !== "KeyHash" || credential2._tag !== "KeyHash") {
571+
throw new Error("Expected KeyHash credentials")
572+
}
573+
574+
// Create a 2-of-2 multi-sig native script
575+
const script1 = NativeScripts.makeScriptPubKey(credential1.hash)
576+
const script2 = NativeScripts.makeScriptPubKey(credential2.hash)
577+
const multiSigScript = NativeScripts.makeScriptAll([script1.script, script2.script])
578+
579+
const scriptHash = ScriptHash.fromScript(multiSigScript)
580+
581+
// Create script address
582+
const scriptAddress = new Address.Address({
583+
networkId: 0,
584+
paymentCredential: scriptHash,
585+
stakingCredential: undefined
586+
})
587+
588+
// Step 1: Create a UTxO with the native script as a reference script
589+
const refScriptSignBuilder = await client1
590+
.newTx()
591+
.payToAddress({
592+
address: address1,
593+
assets: Core.Assets.fromLovelace(5_000_000n),
594+
script: multiSigScript
595+
})
596+
.build()
597+
598+
const refScriptSubmitBuilder = await refScriptSignBuilder.sign()
599+
const refScriptTxHash = await refScriptSubmitBuilder.submit()
600+
601+
expect(refScriptTxHash.length).toBe(64)
602+
await client1.awaitTx(refScriptTxHash, 1000)
603+
await new Promise((resolve) => setTimeout(resolve, 2_000))
604+
605+
// Step 2: Fund the script address
606+
const fundSignBuilder = await client1
607+
.newTx()
608+
.payToAddress({
609+
address: scriptAddress,
610+
assets: Core.Assets.fromLovelace(10_000_000n)
611+
})
612+
.build()
613+
614+
const fundSubmitBuilder = await fundSignBuilder.sign()
615+
const fundTxHash = await fundSubmitBuilder.submit()
616+
617+
await client1.awaitTx(fundTxHash, 1000)
618+
await new Promise((resolve) => setTimeout(resolve, 2_000))
619+
620+
// Fetch UTxOs at the script address
621+
const scriptUtxos = await client1.getUtxos(scriptAddress)
622+
expect(scriptUtxos.length).toBeGreaterThan(0)
623+
624+
const scriptUtxo = scriptUtxos.find((u) => UTxO.toOutRefString(u).startsWith(fundTxHash))
625+
expect(scriptUtxo).toBeDefined()
626+
627+
// Find the UTxO with the reference script (fetch AFTER fund tx to get fresh state)
628+
const walletUtxos = await client1.getUtxos(address1)
629+
const refScriptUtxo = walletUtxos.find(
630+
(u) => UTxO.toOutRefString(u).startsWith(refScriptTxHash) && u.scriptRef !== undefined
631+
)
632+
expect(refScriptUtxo).toBeDefined()
633+
expect(refScriptUtxo!.scriptRef).toBeDefined()
634+
635+
// Step 3: Spend from script address using readFrom (reference input) instead of attachScript
636+
// This tests that native scripts provided via reference inputs don't incorrectly require redeemers
637+
const spendSignBuilder = await client1
638+
.newTx()
639+
.readFrom({ referenceInputs: [refScriptUtxo!] }) // Reference the UTxO with the script
640+
.collectFrom({ inputs: [scriptUtxo!] }) // Spend from the script address
641+
.payToAddress({
642+
address: address1,
643+
assets: Core.Assets.fromLovelace(5_000_000n)
644+
})
645+
.build()
646+
647+
const spendTx = await spendSignBuilder.toTransaction()
648+
649+
// Both clients must sign (native script requires signatures)
650+
const witness1 = await spendSignBuilder.partialSign()
651+
const witness2 = await client2.signTx(spendTx)
652+
653+
const spendSubmitBuilder = await spendSignBuilder.assemble([witness1, witness2])
654+
const spendTxHash = await spendSubmitBuilder.submit()
655+
656+
expect(spendTxHash.length).toBe(64)
657+
const spendConfirmed = await client1.awaitTx(spendTxHash, 1000)
658+
expect(spendConfirmed).toBe(true)
659+
})
561660
})

packages/evolution-devnet/test/utils/utxo-helpers.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { Core } from "@evolution-sdk/evolution"
22
import * as CoreAddress from "@evolution-sdk/evolution/core/Address"
33
import * as CoreData from "@evolution-sdk/evolution/core/Data"
44
import * as CoreDatumOption from "@evolution-sdk/evolution/core/DatumOption"
5-
import * as CoreScript from "@evolution-sdk/evolution/core/Script"
6-
import * as CoreScriptRef from "@evolution-sdk/evolution/core/ScriptRef"
5+
import type * as CoreScript from "@evolution-sdk/evolution/core/Script"
76
import * as CoreTransactionHash from "@evolution-sdk/evolution/core/TransactionHash"
87
import * as CoreUTxO from "@evolution-sdk/evolution/core/UTxO"
98
import type * as Datum from "@evolution-sdk/evolution/sdk/Datum"
@@ -103,20 +102,12 @@ export const createCoreTestUtxo = (options: CreateCoreTestUtxoOptions): CoreUTxO
103102
}
104103
}
105104

106-
// Convert Core Script to ScriptRef
107-
let coreScriptRef: CoreScriptRef.ScriptRef | undefined
108-
if (scriptRef) {
109-
// Convert Script to ScriptRef bytes (CBOR-encoded script)
110-
const scriptBytes = CoreScript.toCBOR(scriptRef)
111-
coreScriptRef = new CoreScriptRef.ScriptRef({ bytes: scriptBytes })
112-
}
113-
114105
return new CoreUTxO.UTxO({
115106
transactionId: CoreTransactionHash.fromHex(paddedTxId),
116107
index: BigInt(index),
117108
address: CoreAddress.fromBech32(address),
118109
assets,
119-
scriptRef: coreScriptRef,
110+
scriptRef,
120111
datumOption: coreDatumOption
121112
})
122113
}

packages/evolution/src/core/NativeScripts.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,3 +636,45 @@ export const countRequiredSigners = (script: NativeScriptVariants): number => {
636636
return 0
637637
}
638638
}
639+
/**
640+
* Extract all key hashes from a native script.
641+
* Recursively traverses nested scripts to find all ScriptPubKey key hashes.
642+
*
643+
* @example
644+
* import { NativeScripts } from "@evolution-sdk/core"
645+
*
646+
* const script = NativeScripts.makeScriptAll([
647+
* NativeScripts.makeScriptPubKey(keyHash1).script,
648+
* NativeScripts.makeScriptPubKey(keyHash2).script
649+
* ])
650+
* const keyHashes = NativeScripts.extractKeyHashes(script.script)
651+
* // Returns Set<Uint8Array> containing keyHash1 and keyHash2
652+
*
653+
* @since 2.0.0
654+
* @category utilities
655+
*/
656+
export const extractKeyHashes = (script: NativeScriptVariants): ReadonlyArray<Uint8Array> => {
657+
const keyHashes: Array<Uint8Array> = []
658+
659+
const traverse = (s: NativeScriptVariants): void => {
660+
switch (s._tag) {
661+
case "ScriptPubKey":
662+
keyHashes.push(s.keyHash)
663+
break
664+
case "ScriptAll":
665+
case "ScriptAny":
666+
for (const nested of s.scripts) traverse(nested)
667+
break
668+
case "ScriptNOfK":
669+
for (const nested of s.scripts) traverse(nested)
670+
break
671+
case "InvalidBefore":
672+
case "InvalidHereafter":
673+
// Time-based scripts don't contain key hashes
674+
break
675+
}
676+
}
677+
678+
traverse(script)
679+
return keyHashes
680+
}

packages/evolution/src/sdk/builders/TxBuilderImpl.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -152,22 +152,26 @@ export const calculateReferenceScriptFee = (
152152
referenceInputs: ReadonlyArray<CoreUTxO.UTxO>
153153
): Effect.Effect<bigint, TransactionBuilderError> =>
154154
Effect.gen(function* () {
155-
// Calculate total reference script size in bytes
155+
// Calculate total reference script size in bytes (both native and Plutus)
156+
// Per ADR 2024-08-14_009: "Native scripts that are used as reference scripts also contribute their size to this calculation"
156157
let totalScriptSize = 0
157158

158159
for (const utxo of referenceInputs) {
159160
if (utxo.scriptRef) {
160-
// Get script CBOR bytes length from Core Script type
161161
const scriptBytes = CoreScript.toCBOR(utxo.scriptRef).length
162162
totalScriptSize += scriptBytes
163+
const scriptType = utxo.scriptRef._tag === "NativeScript" ? "Native" : "Plutus"
164+
yield* Effect.logDebug(`[RefScriptFee] ${scriptType} script in ref input: ${scriptBytes} bytes`)
163165
}
164166
}
165167

166-
// No reference scripts = no fee
168+
// No reference scripts = no tiered fee
167169
if (totalScriptSize === 0) {
168170
return 0n
169171
}
170172

173+
yield* Effect.logDebug(`[RefScriptFee] Total reference script size: ${totalScriptSize} bytes`)
174+
171175
// Check maximum size limit (200KB)
172176
if (totalScriptSize > 200_000) {
173177
return yield* Effect.fail(
@@ -177,7 +181,7 @@ export const calculateReferenceScriptFee = (
177181
)
178182
}
179183

180-
// Calculate tiered fees
184+
// Calculate tiered fees for all reference scripts
181185
let fee = 0n
182186
let remainingSize = totalScriptSize
183187
let tierIndex = 0
@@ -188,11 +192,14 @@ export const calculateReferenceScriptFee = (
188192
const bytesInThisTier = Math.min(remainingSize, tierSize)
189193
const tierFee = BigInt(Math.ceil(bytesInThisTier * tierPrices[tierIndex]!))
190194
fee += tierFee
195+
yield* Effect.logDebug(`[RefScriptFee] Tier ${tierIndex + 1}: ${bytesInThisTier} bytes × ${tierPrices[tierIndex]} lovelace/byte = ${tierFee} lovelace`)
191196

192197
remainingSize -= tierSize
193198
tierIndex++
194199
}
195200

201+
yield* Effect.logDebug(`[RefScriptFee] Total tiered fee (Plutus only): ${fee} lovelace`)
202+
196203
return fee
197204
})
198205

@@ -1224,6 +1231,19 @@ export const calculateFeeIteratively = (
12241231
? (state.requiredSigners as [KeyHash.KeyHash, ...Array<KeyHash.KeyHash>])
12251232
: undefined
12261233

1234+
// Build referenceInputs for size estimation
1235+
// Reference inputs add to transaction size and must be included in fee calculation
1236+
let referenceInputsForFee:
1237+
| readonly [TransactionInput.TransactionInput, ...Array<TransactionInput.TransactionInput>]
1238+
| undefined
1239+
if (state.referenceInputs.length > 0) {
1240+
const refInputs = yield* buildTransactionInputs(state.referenceInputs)
1241+
referenceInputsForFee = refInputs as readonly [
1242+
TransactionInput.TransactionInput,
1243+
...Array<TransactionInput.TransactionInput>,
1244+
]
1245+
}
1246+
12271247
while (iterations < maxIterations) {
12281248
// Build transaction with current fee estimate
12291249
const body = new TransactionBody.TransactionBody({
@@ -1237,7 +1257,8 @@ export const calculateFeeIteratively = (
12371257
totalCollateral, // Include total collateral for accurate size
12381258
certificates, // Include certificates for accurate size calculation
12391259
withdrawals, // Include withdrawals for accurate size calculation
1240-
requiredSigners // Include requiredSigners for accurate size calculation
1260+
requiredSigners, // Include requiredSigners for accurate size calculation
1261+
referenceInputs: referenceInputsForFee // Include reference inputs for accurate size calculation
12411262
})
12421263

12431264
const transaction = new Transaction.Transaction({
@@ -1249,9 +1270,20 @@ export const calculateFeeIteratively = (
12491270

12501271
// Calculate size
12511272
const size = yield* calculateTransactionSize(transaction)
1273+
1274+
// Add reference script sizes to transaction size for base fee calculation
1275+
// Despite ADR docs, actual node behavior includes ref scripts in tx size for base fee
1276+
let refScriptSize = 0
1277+
for (const utxo of state.referenceInputs) {
1278+
if (utxo.scriptRef) {
1279+
const scriptBytes = CoreScript.toCBOR(utxo.scriptRef).length
1280+
refScriptSize += scriptBytes
1281+
}
1282+
}
1283+
const sizeWithRefScripts = size + refScriptSize
12521284

1253-
// Calculate base fee based on size
1254-
const baseFee = calculateMinimumFee(size, {
1285+
// Calculate base fee based on size including reference scripts
1286+
const baseFee = calculateMinimumFee(sizeWithRefScripts, {
12551287
minFeeCoefficient: protocolParams.minFeeCoefficient,
12561288
minFeeConstant: protocolParams.minFeeConstant
12571289
})

packages/evolution/src/sdk/builders/operations/AddSigner.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* @since 2.0.0
1010
*/
1111

12-
import { Effect, Ref } from "effect"
12+
import { Effect, Equal, Ref } from "effect"
1313

1414
import { TxContext } from "../TransactionBuilder.js"
1515
import type { AddSignerParams } from "./Operations.js"
@@ -31,11 +31,7 @@ export const createAddSignerProgram = (params: AddSignerParams) =>
3131

3232
yield* Ref.update(ctx, (state) => {
3333
// Check if this key hash is already in requiredSigners (deduplicate)
34-
const alreadyExists = state.requiredSigners.some(
35-
(existing) =>
36-
existing.hash.length === params.keyHash.hash.length &&
37-
existing.hash.every((b, i) => b === params.keyHash.hash[i])
38-
)
34+
const alreadyExists = state.requiredSigners.some((existing) => Equal.equals(existing, params.keyHash))
3935

4036
if (alreadyExists) {
4137
return state

packages/evolution/src/sdk/builders/operations/Collect.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,29 @@ export const createCollectFromProgram = (params: CollectFromParams) =>
5252
// 2. Filter script-locked UTxOs
5353
const scriptUtxos = yield* filterScriptUtxos(params.inputs)
5454

55-
// 3. Filter out native script UTxOs (those with attached native scripts don't need redeemers)
55+
// 3. Filter out native script UTxOs (those with native scripts don't need redeemers)
5656
// Native scripts are validated by signatures, not redeemers
57+
// Check: attached scripts, inline scriptRef, and reference inputs
5758
const plutusScriptUtxos = scriptUtxos.filter((utxo) => {
5859
const credential = utxo.address.paymentCredential
5960
if (credential?._tag !== "ScriptHash") return false
6061

6162
const scriptHashHex = ScriptHash.toHex(credential)
62-
const attachedScript = state.scripts.get(scriptHashHex)
6363

64-
// If script is attached and is a NativeScript, no redeemer needed
64+
// Check 1: Script attached via attachScript()
65+
const attachedScript = state.scripts.get(scriptHashHex)
6566
if (attachedScript?._tag === "NativeScript") return false
6667

67-
// Otherwise it's a Plutus script (or script not attached yet)
68+
// Check 2: Script inline in the UTxO being spent
69+
if (utxo.scriptRef?._tag === "NativeScript") return false
70+
71+
// Check 3: Script available via reference input
72+
const refScript = state.referenceInputs.find((ref) =>
73+
ref.scriptRef && ScriptHash.toHex(ScriptHash.fromScript(ref.scriptRef)) === scriptHashHex
74+
)
75+
if (refScript?.scriptRef?._tag === "NativeScript") return false
76+
77+
// Otherwise it's a Plutus script (or script not found)
6878
return true
6979
})
7080

packages/evolution/src/sdk/builders/phases/FeeCalculation.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,14 @@ export const executeFeeCalculation = (): Effect.Effect<
8585
priceStep: protocolParams.priceStep
8686
})
8787

88-
yield* Effect.logDebug(`[FeeCalculation] Base fee: ${baseFee}`)
88+
yield* Effect.logDebug(`[FeeCalculation] Base fee (includes ref script size): ${baseFee}`)
8989

90-
// Step 4a: Add reference script fee if reference inputs are present
90+
// Step 4a: Add tiered reference script fee for all reference scripts
9191
const refScriptFee = yield* calculateReferenceScriptFee(state.referenceInputs)
92-
yield* Effect.logDebug(`[FeeCalculation] Reference script fee: ${refScriptFee}`)
92+
yield* Effect.logDebug(`[FeeCalculation] Tiered reference script fee: ${refScriptFee}`)
9393

9494
const calculatedFee = baseFee + refScriptFee
95-
yield* Effect.logDebug(`[FeeCalculation] Total fee (base + refScript): ${calculatedFee}`)
95+
yield* Effect.logDebug(`[FeeCalculation] Total fee: ${calculatedFee}`)
9696

9797
// Step 5: Calculate leftover after fee NOW (after fee is known)
9898
const inputAssets = state.totalInputAssets

0 commit comments

Comments
 (0)