diff --git a/packages/mock-chain/src/index.ts b/packages/mock-chain/src/index.ts index 62a7bac2..9c726b27 100644 --- a/packages/mock-chain/src/index.ts +++ b/packages/mock-chain/src/index.ts @@ -2,3 +2,4 @@ export * from "./execution"; export * from "./mockChain"; export * from "./party"; export * from "./objectMocking"; +export * from "./transactionChecks"; diff --git a/packages/mock-chain/src/mockChain.spec.ts b/packages/mock-chain/src/mockChain.spec.ts index 63ab9d45..7a9fba3f 100644 --- a/packages/mock-chain/src/mockChain.spec.ts +++ b/packages/mock-chain/src/mockChain.spec.ts @@ -554,3 +554,89 @@ describe("Contract execution and chain mocking", () => { expect(chain.timestamp - params.timestamp).to.be.equal(0); }); }); + +describe("Transaction validation checks", () => { + it("Should validate minimum nanoergs per box", () => { + const chain = new MockChain(); + const alice = chain.newParty("Alice"); + alice.addBalance({ nanoergs: 10000000n }); + + const unsignedTransaction = new TransactionBuilder(chain.height) + .from(alice.utxos) + .to(new OutputBuilder("100", alice.address)) // Too small value + .sendChangeTo(alice.address) + .build(); + + // Should fail with validation error + expect(() => chain.execute(unsignedTransaction, { signers: [alice] })) + .to.throw("Transaction validation failed"); + }); + + it("Should validate miner fee", () => { + const chain = new MockChain(); + const alice = chain.newParty("Alice"); + alice.addBalance({ nanoergs: 10000000n }); + + // Transaction without fee + const unsignedTransaction = new TransactionBuilder(chain.height) + .from(alice.utxos) + .to(new OutputBuilder(SAFE_MIN_BOX_VALUE, alice.address)) + .sendChangeTo(alice.address) + .build(); + + // Should pass but with warning (logged if log: true) + const consoleMock = vi.spyOn(console, "log").mockImplementationOnce(() => {}); + + expect( + chain.execute(unsignedTransaction, { signers: [alice], log: true }) + ).to.be.true; + + expect(consoleMock).toHaveBeenCalledWith( + expect.stringContaining("No miner fee box found") + ); + + consoleMock.mockRestore(); + }); + + it("Should allow disabling checks", () => { + const chain = new MockChain(); + const alice = chain.newParty("Alice"); + alice.addBalance({ nanoergs: 10000000n }); + + const unsignedTransaction = new TransactionBuilder(chain.height) + .from(alice.utxos) + .to(new OutputBuilder("100", alice.address)) // Too small value + .sendChangeTo(alice.address) + .build(); + + // Should pass when checks are disabled + expect( + chain.execute(unsignedTransaction, { signers: [alice], checks: false }) + ).to.be.true; + }); + + it("Should support custom fee trees", () => { + const chain = new MockChain(); + const alice = chain.newParty("Alice"); + const bob = chain.newParty("Bob"); // Create another party to use their ErgoTree + alice.addBalance({ nanoergs: 10000000n }); + + // Use Bob's ErgoTree as a custom fee contract + const customFeeTree = bob.ergoTree; + + const unsignedTransaction = new TransactionBuilder(chain.height) + .from(alice.utxos) + .to(new OutputBuilder(SAFE_MIN_BOX_VALUE, alice.address)) + .to(new OutputBuilder(RECOMMENDED_MIN_FEE_VALUE, customFeeTree)) // Custom fee box + .sendChangeTo(alice.address) + .build(); + + // Should pass with custom fee tree + expect( + chain.execute(unsignedTransaction, { + signers: [alice], + checks: { customFeeErgoTrees: [customFeeTree] } + }) + ).to.be.true; + }); +}); diff --git a/packages/mock-chain/src/mockChain.ts b/packages/mock-chain/src/mockChain.ts index 30d65220..11f90872 100644 --- a/packages/mock-chain/src/mockChain.ts +++ b/packages/mock-chain/src/mockChain.ts @@ -15,6 +15,7 @@ import { printDiff } from "./balancePrinting"; import { BLOCKCHAIN_PARAMETERS, execute } from "./execution"; import { mockBlockchainStateContext } from "./objectMocking"; import { KeyedMockChainParty, type MockChainParty, NonKeyedMockChainParty } from "./party"; +import { type TransactionCheckOptions, validateTransaction } from "./transactionChecks"; const BLOCK_TIME_MS = 120000; const DEFAULT_HEIGHT = 1; @@ -36,6 +37,7 @@ export type TransactionExecutionOptions = { signers?: KeyedMockChainParty[]; throw?: boolean; log?: boolean; + checks?: TransactionCheckOptions | false; }; export type MockChainOptions = { @@ -178,6 +180,30 @@ export class MockChain { ? unsignedTransaction.toEIP12Object() : unsignedTransaction; + // Perform transaction validation checks if enabled (default is to run checks unless explicitly disabled) + if (options?.checks !== false) { + const validationResult = validateTransaction(txObject, this.#tip.parameters, options?.checks); + + if (options?.log) { + // Log warnings + for (const warning of validationResult.warnings) { + log(pc.yellow(`${pc.bgYellow(pc.bold(" Warning "))} ${warning}`)); + } + + // Log errors + for (const error of validationResult.errors) { + log(pc.red(`${pc.bgRed(pc.bold(" Check Error "))} ${error}`)); + } + } + + if (!validationResult.success) { + if (options?.throw !== false) { + throw new Error(`Transaction validation failed:\n${validationResult.errors.join("\n")}`); + } + return false; + } + } + const result = execute(txObject, keys, { context, baseCost, diff --git a/packages/mock-chain/src/transactionChecks.spec.ts b/packages/mock-chain/src/transactionChecks.spec.ts new file mode 100644 index 00000000..a96a97ac --- /dev/null +++ b/packages/mock-chain/src/transactionChecks.spec.ts @@ -0,0 +1,395 @@ +import { + type BoxCandidate, + type EIP12UnsignedInput, + type EIP12UnsignedTransaction, + FEE_CONTRACT +} from "@fleet-sdk/common"; +import { describe, expect, it } from "vitest"; +import { BLOCKCHAIN_PARAMETERS } from "./execution"; +import { + DEFAULT_MIN_FEE_PER_BYTE, + type TransactionCheckOptions, + checkMinNanoergsPerBox, + checkMinerFee, + isFeeContract, + validateTransaction +} from "./transactionChecks"; + +describe("Transaction Checks", () => { + describe("checkMinNanoergsPerBox", () => { + it("Should pass when all boxes meet minimum value requirements", () => { + const outputs: BoxCandidate[] = [ + { + value: "1000000", // 1,000,000 nanoergs + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 100, + assets: [], + additionalRegisters: {} + } + ]; + + const errors = checkMinNanoergsPerBox(outputs, BLOCKCHAIN_PARAMETERS.minValuePerByte); + expect(errors).to.have.length(0); + }); + + it("Should fail when a box doesn't meet minimum value requirement", () => { + const outputs: BoxCandidate[] = [ + { + value: "1000", // Too small + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 100, + assets: [], + additionalRegisters: {} + } + ]; + + const errors = checkMinNanoergsPerBox(outputs, BLOCKCHAIN_PARAMETERS.minValuePerByte); + expect(errors).to.have.length(1); + expect(errors[0]).to.include("insufficient value"); + }); + + it("Should calculate minimum value based on box size", () => { + const outputs: BoxCandidate[] = [ + { + value: "50000", + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 100, + assets: [ + { tokenId: "0".repeat(64), amount: "100" }, + { tokenId: "1".repeat(64), amount: "200" } + ], + additionalRegisters: { + R4: "0580897a", + R5: `0e20${"a".repeat(64)}` + } + } + ]; + + const errors = checkMinNanoergsPerBox(outputs, BLOCKCHAIN_PARAMETERS.minValuePerByte); + expect(errors).to.have.length(1); + expect(errors[0]).to.include("insufficient value"); + expect(errors[0]).to.include("bytes"); + }); + }); + + describe("checkMinerFee", () => { + it("Should pass when fee box is present and meets threshold", () => { + const mockInput: EIP12UnsignedInput = { + boxId: "a".repeat(64), + extension: {}, + transactionId: "b".repeat(64), + index: 0, + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 99, + value: "2100000", + assets: [], + additionalRegisters: {} + }; + + const transaction: EIP12UnsignedTransaction = { + inputs: [mockInput], + dataInputs: [], + outputs: [ + { + value: "1000000", + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 100, + assets: [], + additionalRegisters: {} + }, + { + value: "1100000", // Fee box + ergoTree: FEE_CONTRACT, + creationHeight: 100, + assets: [], + additionalRegisters: {} + } + ] + }; + + const result = checkMinerFee(transaction, DEFAULT_MIN_FEE_PER_BYTE); + expect(result.errors).to.have.length(0); + expect(result.warnings).to.have.length(0); + }); + + it("Should warn when no fee box is present", () => { + const mockInput: EIP12UnsignedInput = { + boxId: "a".repeat(64), + extension: {}, + transactionId: "b".repeat(64), + index: 0, + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 99, + value: "1000000", + assets: [], + additionalRegisters: {} + }; + + const transaction: EIP12UnsignedTransaction = { + inputs: [mockInput], + dataInputs: [], + outputs: [ + { + value: "1000000", + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 100, + assets: [], + additionalRegisters: {} + } + ] + }; + + const result = checkMinerFee(transaction, DEFAULT_MIN_FEE_PER_BYTE); + expect(result.errors).to.have.length(0); + expect(result.warnings).to.have.length(1); + expect(result.warnings[0]).to.include("No miner fee box found"); + }); + + it("Should fail when fee is below threshold", () => { + const mockInput: EIP12UnsignedInput = { + boxId: "a".repeat(64), + extension: {}, + transactionId: "b".repeat(64), + index: 0, + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 99, + value: "1000100", + assets: [], + additionalRegisters: {} + }; + + const transaction: EIP12UnsignedTransaction = { + inputs: [mockInput], + dataInputs: [], + outputs: [ + { + value: "1000000", + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 100, + assets: [], + additionalRegisters: {} + }, + { + value: "100", // Too small fee + ergoTree: FEE_CONTRACT, + creationHeight: 100, + assets: [], + additionalRegisters: {} + } + ] + }; + + const result = checkMinerFee(transaction, DEFAULT_MIN_FEE_PER_BYTE); + expect(result.errors).to.have.length(1); + expect(result.errors[0]).to.include("below minimum"); + }); + + it("Should recognize custom fee trees", () => { + const customFeeTree = + "1005040004000e36100204a00b08cd0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798ea02d192a39a8cc7a70173007301"; + const mockInput: EIP12UnsignedInput = { + boxId: "a".repeat(64), + extension: {}, + transactionId: "b".repeat(64), + index: 0, + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 99, + value: "2000000", + assets: [], + additionalRegisters: {} + }; + + const transaction: EIP12UnsignedTransaction = { + inputs: [mockInput], + dataInputs: [], + outputs: [ + { + value: "2000000", // Fee box with custom tree + ergoTree: customFeeTree, + creationHeight: 100, + assets: [], + additionalRegisters: {} + } + ] + }; + + const result = checkMinerFee(transaction, DEFAULT_MIN_FEE_PER_BYTE, [customFeeTree]); + expect(result.errors).to.have.length(0); + expect(result.warnings).to.have.length(0); + }); + + it("Should handle multiple fee boxes", () => { + const mockInput: EIP12UnsignedInput = { + boxId: "a".repeat(64), + extension: {}, + transactionId: "b".repeat(64), + index: 0, + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 99, + value: "1200000", + assets: [], + additionalRegisters: {} + }; + + const transaction: EIP12UnsignedTransaction = { + inputs: [mockInput], + dataInputs: [], + outputs: [ + { + value: "600000", // First fee box + ergoTree: FEE_CONTRACT, + creationHeight: 100, + assets: [], + additionalRegisters: {} + }, + { + value: "600000", // Second fee box + ergoTree: FEE_CONTRACT, + creationHeight: 100, + assets: [], + additionalRegisters: {} + } + ] + }; + + const result = checkMinerFee(transaction, DEFAULT_MIN_FEE_PER_BYTE); + expect(result.errors).to.have.length(0); + expect(result.warnings).to.have.length(1); + expect(result.warnings[0]).to.include("2 fee boxes"); + expect(result.warnings[0]).to.include("1200000 nanoergs"); + }); + }); + + describe("validateTransaction", () => { + it("Should perform all checks by default", () => { + const mockInput: EIP12UnsignedInput = { + boxId: "a".repeat(64), + extension: {}, + transactionId: "b".repeat(64), + index: 0, + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 99, + value: "1000", + assets: [], + additionalRegisters: {} + }; + + const transaction: EIP12UnsignedTransaction = { + inputs: [mockInput], + dataInputs: [], + outputs: [ + { + value: "1000", // Too small + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 100, + assets: [], + additionalRegisters: {} + } + ] + }; + + const result = validateTransaction(transaction, BLOCKCHAIN_PARAMETERS); + expect(result.success).to.be.false; + expect(result.errors.length).to.be.greaterThan(0); + expect(result.warnings).to.have.length(1); // No fee box warning + }); + + it("Should allow disabling specific checks", () => { + const mockInput: EIP12UnsignedInput = { + boxId: "a".repeat(64), + extension: {}, + transactionId: "b".repeat(64), + index: 0, + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 99, + value: "1000", + assets: [], + additionalRegisters: {} + }; + + const transaction: EIP12UnsignedTransaction = { + inputs: [mockInput], + dataInputs: [], + outputs: [ + { + value: "1000", // Too small, but check is disabled + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 100, + assets: [], + additionalRegisters: {} + } + ] + }; + + const options: TransactionCheckOptions = { + checkMinNanoergsPerBox: false, + checkMinerFee: false + }; + + const result = validateTransaction(transaction, BLOCKCHAIN_PARAMETERS, options); + expect(result.success).to.be.true; + expect(result.errors).to.have.length(0); + expect(result.warnings).to.have.length(0); + }); + + it("Should use custom fee thresholds", () => { + const mockInput: EIP12UnsignedInput = { + boxId: "a".repeat(64), + extension: {}, + transactionId: "b".repeat(64), + index: 0, + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 99, + value: "2000000", + assets: [], + additionalRegisters: {} + }; + + const transaction: EIP12UnsignedTransaction = { + inputs: [mockInput], + dataInputs: [], + outputs: [ + { + value: "1000000", + ergoTree: "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482", + creationHeight: 100, + assets: [], + additionalRegisters: {} + }, + { + value: "100000", // Small fee but meets box minimum value requirements + ergoTree: FEE_CONTRACT, + creationHeight: 100, + assets: [], + additionalRegisters: {} + } + ] + }; + + const options: TransactionCheckOptions = { + minFeePerByte: BigInt(100) // Matches the default; + }; + + const result = validateTransaction(transaction, BLOCKCHAIN_PARAMETERS, options); + expect(result.success).to.be.true; + expect(result.errors).to.have.length(0); + }); + }); + + describe("isFeeContract", () => { + it("Should recognize standard fee contract", () => { + expect(isFeeContract(FEE_CONTRACT)).to.be.true; + }); + + it("Should not recognize random ErgoTree as fee contract", () => { + const randomTree = "0008cd03b196b194d3360c21c1d42d52c32a65e996b98525781bd7bb7f5fdfec596a0482"; + expect(isFeeContract(randomTree)).to.be.false; + }); + + it("Should recognize custom fee contracts", () => { + const customFee = "custom_fee_tree_123"; + expect(isFeeContract(customFee, [customFee])).to.be.true; + expect(isFeeContract("other_tree", [customFee])).to.be.false; + }); + }); +}); diff --git a/packages/mock-chain/src/transactionChecks.ts b/packages/mock-chain/src/transactionChecks.ts new file mode 100644 index 00000000..c256067b --- /dev/null +++ b/packages/mock-chain/src/transactionChecks.ts @@ -0,0 +1,221 @@ +import { + type BoxCandidate, + type EIP12UnsignedTransaction, + FEE_CONTRACT, + type HexString, + byteSizeOf, + ensureBigInt +} from "@fleet-sdk/common"; +import { estimateBoxSize } from "@fleet-sdk/serializer"; +import type { BlockchainParameters } from "sigmastate-js/main"; + +/** + * Options for customizing transaction validation checks. + */ +export type TransactionCheckOptions = { + /** Enable/disable minimum nanoergs per box check (default: true) */ + checkMinNanoergsPerBox?: boolean; + /** Enable/disable miner fee validation (default: true) */ + checkMinerFee?: boolean; + /** Minimum fee per byte in nanoergs (default: 1000) */ + minFeePerByte?: bigint; + /** Custom fee tree ErgoTree scripts to recognize as valid fee boxes */ + customFeeErgoTrees?: HexString[]; +}; + +/** + * Result of transaction validation checks. + */ +export type TransactionCheckResult = { + success: boolean; + errors: string[]; + warnings: string[]; +}; + +/** + * Default minimum fee per byte in nanoergs. + * This is a conservative threshold that allows realistic transaction fees. + * The Ergo network doesn't enforce a strict fee-per-byte minimum, but this + * helps catch transactions with unreasonably low fees. + */ +export const DEFAULT_MIN_FEE_PER_BYTE = BigInt(100); + +/** + * Validates a transaction against various checks including minimum box values and fees. + * + * @param transaction - The unsigned transaction to validate + * @param parameters - Blockchain parameters containing minValuePerByte + * @param options - Configuration options for the checks + * @returns Validation result with success status and any errors/warnings + */ +export function validateTransaction( + transaction: EIP12UnsignedTransaction, + parameters: BlockchainParameters, + options?: TransactionCheckOptions +): TransactionCheckResult { + const errors: string[] = []; + const warnings: string[] = []; + + const opts = { + checkMinNanoergsPerBox: true, + checkMinerFee: true, + minFeePerByte: DEFAULT_MIN_FEE_PER_BYTE, + customFeeErgoTrees: [], + ...options + }; + + // Check minimum nanoergs per box + if (opts.checkMinNanoergsPerBox) { + const minValueErrors = checkMinNanoergsPerBox(transaction.outputs, parameters.minValuePerByte); + errors.push(...minValueErrors); + } + + // Check miner fee + if (opts.checkMinerFee) { + const feeCheckResult = checkMinerFee(transaction, opts.minFeePerByte, opts.customFeeErgoTrees); + errors.push(...feeCheckResult.errors); + warnings.push(...feeCheckResult.warnings); + } + + return { + success: errors.length === 0, + errors, + warnings + }; +} + +/** + * Checks if each output box meets the minimum nanoergs requirement based on its size. + * + * The minimum value is calculated as: boxSize * minValuePerByte + * + * @param outputs - Transaction output boxes to check + * @param minValuePerByte - Minimum nanoergs per byte (from blockchain parameters) + * @returns Array of error messages for boxes that don't meet the requirement + */ +export function checkMinNanoergsPerBox( + outputs: BoxCandidate[], + minValuePerByte: number +): string[] { + const errors: string[] = []; + + for (let i = 0; i < outputs.length; i++) { + const box = outputs[i]; + const boxValue = ensureBigInt(box.value); + + // Estimate the box size in bytes + const boxSize = estimateBoxSize(box); + + // Calculate minimum required value + const minValue = BigInt(boxSize) * BigInt(minValuePerByte); + + if (boxValue < minValue) { + errors.push( + `Output box ${i} has insufficient value: ${boxValue} nanoergs, ` + + `minimum required: ${minValue} nanoergs (${boxSize} bytes * ${minValuePerByte} nanoergs/byte)` + ); + } + } + + return errors; +} + +/** + * Checks if the transaction includes a valid miner fee box and meets the fee per byte threshold. + * + * @param transaction - The unsigned transaction to check + * @param minFeePerByte - Minimum fee per byte in nanoergs + * @param customFeeErgoTrees - Additional ErgoTree scripts to recognize as fee boxes + * @returns Check result with errors and warnings + */ +export function checkMinerFee( + transaction: EIP12UnsignedTransaction, + minFeePerByte: bigint, + customFeeErgoTrees: HexString[] = [] +): { errors: string[]; warnings: string[] } { + const errors: string[] = []; + const warnings: string[] = []; + + // Find fee boxes (standard fee contract or custom fee trees) + const validFeeErgoTrees = [FEE_CONTRACT, ...customFeeErgoTrees]; + const feeBoxes = transaction.outputs.filter((box) => validFeeErgoTrees.includes(box.ergoTree)); + + if (feeBoxes.length === 0) { + warnings.push("No miner fee box found in transaction outputs"); + return { errors, warnings }; + } + + // Calculate total fee + const totalFee = feeBoxes.reduce((sum, box) => sum + ensureBigInt(box.value), BigInt(0)); + + // Calculate transaction size + const txSize = calculateTransactionSize(transaction); + + // Calculate fee per byte + const feePerByte = txSize > 0 ? totalFee / BigInt(txSize) : BigInt(0); + + if (feePerByte < minFeePerByte) { + errors.push( + `Transaction fee per byte (${feePerByte} nanoergs/byte) is below minimum ` + + `(${minFeePerByte} nanoergs/byte). Total fee: ${totalFee} nanoergs, ` + + `Transaction size: ${txSize} bytes` + ); + } + + // Check for multiple fee boxes (unusual but not necessarily an error) + if (feeBoxes.length > 1) { + warnings.push( + `Transaction contains ${feeBoxes.length} fee boxes with total fee: ${totalFee} nanoergs` + ); + } + + return { errors, warnings }; +} + +/** + * Estimates the size of a transaction in bytes. + * This is a simplified estimation that may not be 100% accurate but is sufficient for fee checks. + * + * @param transaction - The unsigned transaction + * @returns Estimated size in bytes + */ +function calculateTransactionSize(transaction: EIP12UnsignedTransaction): number { + let size = 0; + + // Base transaction overhead (simplified estimation) + size += 100; // Base overhead for transaction structure + + // Inputs size (simplified: boxId + proof placeholder) + for (const input of transaction.inputs) { + size += byteSizeOf(input.boxId); + size += 100; // Estimated proof size (varies based on script complexity) + + if (input.extension && Object.keys(input.extension).length > 0) { + // Add extension size if present + size += JSON.stringify(input.extension).length; // Simplified estimation + } + } + + // Data inputs size + for (const dataInput of transaction.dataInputs) { + size += byteSizeOf(dataInput.boxId); + } + + // Outputs size + for (const output of transaction.outputs) { + size += estimateBoxSize(output); + } + + return size; +} + +/** + * Checks if an ErgoTree script is a recognized fee contract. + * + * @param ergoTree - The ErgoTree script to check + * @param customFeeErgoTrees - Additional custom fee trees to recognize + * @returns True if the ErgoTree is a fee contract + */ +export function isFeeContract(ergoTree: HexString, customFeeErgoTrees: HexString[] = []): boolean { + return ergoTree === FEE_CONTRACT || customFeeErgoTrees.includes(ergoTree); +}