From fbd02561447a62ad8f16a3136f990f0bedd5dafb Mon Sep 17 00:00:00 2001 From: Rosco Kalis Date: Tue, 15 Sep 2026 11:39:50 +0200 Subject: [PATCH] fix: check inputs' locking bytecode against unlocker locking bytecode + add tx validation to MockNetworkProvider --- packages/cashscript/src/Contract.ts | 8 +- packages/cashscript/src/Errors.ts | 17 ++++ packages/cashscript/src/SignatureTemplate.ts | 1 - packages/cashscript/src/TransactionBuilder.ts | 13 ++- packages/cashscript/src/debugging.ts | 27 +----- packages/cashscript/src/interfaces.ts | 10 +- .../cashscript/src/libauth-template/utils.ts | 43 ++++++++- .../src/network/ElectrumNetworkProvider.ts | 7 +- .../src/network/MockNetworkProvider.ts | 93 +++++++++++++------ .../cashscript/src/network/NetworkProvider.ts | 6 +- packages/cashscript/src/transaction-utils.ts | 14 +-- packages/cashscript/src/utils.ts | 69 ++++++++++++-- .../cashscript/src/walletconnect-utils.ts | 5 +- packages/cashscript/test/Contract.test.ts | 5 +- .../cashscript/test/SignatureTemplate.test.ts | 3 +- .../test/TransactionBuilder.test.ts | 79 ++++++++++++---- packages/cashscript/test/debugging.test.ts | 16 ++-- .../cashscript/test/e2e/MultiContract.test.ts | 8 +- .../e2e/network/MockNetworkProvider.test.ts | 53 +++++++++++ .../test/fixture/libauth-template/fixtures.ts | 5 +- packages/cashscript/test/test-util.ts | 19 ++-- .../test/types/Contract.types.test.ts | 6 +- website/docs/guides/cashtokens.md | 1 + website/docs/guides/optimization.md | 3 +- website/docs/releases/migration-notes.md | 16 ++++ website/docs/releases/release-notes.md | 9 +- website/docs/sdk/electrum-network-provider.md | 9 +- website/docs/sdk/instantiation.md | 7 +- website/docs/sdk/network-provider.md | 13 ++- website/docs/sdk/other-network-providers.md | 23 +++-- website/docs/sdk/transaction-builder.md | 6 +- 31 files changed, 434 insertions(+), 160 deletions(-) diff --git a/packages/cashscript/src/Contract.ts b/packages/cashscript/src/Contract.ts index ff784aeee..fc4170244 100644 --- a/packages/cashscript/src/Contract.ts +++ b/packages/cashscript/src/Contract.ts @@ -13,7 +13,7 @@ import { ConstructorArgument, encodeFunctionArgument, encodeConstructorArguments, FunctionArgument, } from './Argument.js'; import { - Unlocker, ContractOptions, GenerateUnlockingBytecodeOptions, Utxo, ContractType, ContractFunctionUnlocker, + Unlocker, ContractOptions, GenerateUnlockingBytecodeOptions, SpendableUtxo, ContractType, ContractFunctionUnlocker, } from './interfaces.js'; import NetworkProvider from './network/NetworkProvider.js'; import { @@ -84,7 +84,7 @@ class ContractBase { * * @returns A list of UTXOs spendable by this contract. */ - async getUtxos(): Promise { + async getUtxos(): Promise { if (this.contractType === 'p2s') { return this.provider.getUtxosForLockingBytecode(this.bytecode); } @@ -204,9 +204,7 @@ class ContractInternal< return unlockingBytecode; }; - const generateLockingBytecode = (): Uint8Array => hexToBin(this.lockingBytecode); - - return { generateUnlockingBytecode, generateLockingBytecode, contract: this, params: args, abiFunction }; + return { generateUnlockingBytecode, contract: this, params: args, abiFunction }; }; } } diff --git a/packages/cashscript/src/Errors.ts b/packages/cashscript/src/Errors.ts index dd12a9f07..9dc6ed82c 100644 --- a/packages/cashscript/src/Errors.ts +++ b/packages/cashscript/src/Errors.ts @@ -19,6 +19,12 @@ export class UndefinedInputError extends Error { } } +export class InputMissingLockingBytecodeError extends Error { + constructor() { + super('Input UTXO is missing its lockingBytecode. UTXOs fetched from a network provider include it automatically; when constructing UTXOs manually, set lockingBytecode to the hex-encoded locking script of the output.'); + } +} + export class OutputSatoshisTooSmallError extends Error { constructor(satoshis: bigint, minimumAmount: bigint) { super(`Tried to add an output with ${satoshis} satoshis, which is less than the required minimum for this output-type (${minimumAmount})`); @@ -97,6 +103,17 @@ export class UnlockingBytecodeTooLargeError extends Error { } } +export class UnlockerLockingBytecodeMismatchError extends Error { + constructor( + public inputIndex: number, + utxoLockScript: string, + unlockerLockScript: string, + unlockerDescription: string, + ) { + super(`Input #${inputIndex} is locked by ${utxoLockScript}, which does not match the provided unlocker (${unlockerDescription}, corresponding to ${unlockerLockScript}). This transaction would be rejected by the network. Make sure to use an unlocker that matches the address/contract holding the UTXO.`); + } +} + export class TransactionTooLargeError extends Error { constructor(size: number, maximumSize: number) { super(`Transaction size of ${size} is greater than the maximum standard transaction size of ${maximumSize}`); diff --git a/packages/cashscript/src/SignatureTemplate.ts b/packages/cashscript/src/SignatureTemplate.ts index eae5d5fad..f80246efe 100644 --- a/packages/cashscript/src/SignatureTemplate.ts +++ b/packages/cashscript/src/SignatureTemplate.ts @@ -87,7 +87,6 @@ export default class SignatureTemplate { const prevOutScript = publicKeyToP2PKHLockingBytecode(this.publicKey); return { - generateLockingBytecode: () => prevOutScript, generateUnlockingBytecode: ({ transaction, sourceOutputs, inputIndex }: GenerateUnlockingBytecodeOptions) => { const preimage = createSighashPreimage(transaction, sourceOutputs, inputIndex, prevOutScript, this.sighashType); const sighash = hash256(preimage); diff --git a/packages/cashscript/src/TransactionBuilder.ts b/packages/cashscript/src/TransactionBuilder.ts index 171c70285..daad50f24 100644 --- a/packages/cashscript/src/TransactionBuilder.ts +++ b/packages/cashscript/src/TransactionBuilder.ts @@ -12,7 +12,7 @@ import { Output, TransactionDetails, UnlockableUtxo, - Utxo, + SpendableUtxo, InputOptions, isUnlockableUtxo, isStandardUnlockableUtxo, @@ -34,6 +34,7 @@ import { getOutputSize, validateInput, validateOutput, + validateUnlocker, } from './utils.js'; import { FailedTransactionError, @@ -109,7 +110,7 @@ export class TransactionBuilder { * @returns This builder for chaining. * @throws If the UTXO is invalid. */ - addInput(utxo: Utxo, unlocker: Unlocker, options?: InputOptions): this { + addInput(utxo: SpendableUtxo, unlocker: Unlocker, options?: InputOptions): this { return this.addInputs([utxo], unlocker, options); } @@ -122,7 +123,7 @@ export class TransactionBuilder { * @returns This builder for chaining. * @throws If any UTXO is invalid. */ - addInputs(utxos: Utxo[], unlocker: Unlocker, options?: InputOptions): this; + addInputs(utxos: SpendableUtxo[], unlocker: Unlocker, options?: InputOptions): this; /** * Add multiple UTXOs that each carry their own unlocker. @@ -133,7 +134,7 @@ export class TransactionBuilder { */ addInputs(utxos: UnlockableUtxo[]): this; - addInputs(utxos: Utxo[] | UnlockableUtxo[], unlocker?: Unlocker, options?: InputOptions): this { + addInputs(utxos: SpendableUtxo[] | UnlockableUtxo[], unlocker?: Unlocker, options?: InputOptions): this { utxos.forEach((utxo) => validateInput(utxo, this.changeLocks)); if ( (!unlocker && utxos.some((utxo) => !isUnlockableUtxo(utxo))) @@ -142,6 +143,10 @@ export class TransactionBuilder { throw new Error('Either all UTXOs must have an individual unlocker specified, or no UTXOs must have an individual unlocker specified and a shared unlocker must be provided'); } + utxos.forEach((utxo, i) => ( + validateUnlocker(utxo, unlocker ?? (utxo as UnlockableUtxo).unlocker, this.inputs.length + i, this.provider.network) + )); + if (!unlocker) { this.inputs = this.inputs.concat(utxos as UnlockableUtxo[]); return this; diff --git a/packages/cashscript/src/debugging.ts b/packages/cashscript/src/debugging.ts index 3ad2a5df5..ca1d17dcc 100644 --- a/packages/cashscript/src/debugging.ts +++ b/packages/cashscript/src/debugging.ts @@ -1,38 +1,15 @@ -import { AuthenticationErrorCommon, AuthenticationInstruction, AuthenticationProgramCommon, AuthenticationProgramStateCommon, AuthenticationVirtualMachine, ResolvedTransactionCommon, WalletTemplate, WalletTemplateScriptUnlocking, binToHex, createCompiler, createVirtualMachineBch2023, createVirtualMachineBch2025, createVirtualMachineBch2026, createVirtualMachineBchSpec, decodeAuthenticationInstructions, encodeAuthenticationInstruction, walletTemplateToCompilerConfiguration } from '@bitauth/libauth'; +import { AuthenticationErrorCommon, AuthenticationInstruction, AuthenticationProgramCommon, AuthenticationProgramStateCommon, WalletTemplate, WalletTemplateScriptUnlocking, binToHex, createCompiler, decodeAuthenticationInstructions, encodeAuthenticationInstruction, walletTemplateToCompilerConfiguration } from '@bitauth/libauth'; import { Artifact, LogData, LogEntry, Op, PrimitiveType, StackItem, asmToBytecode, bytecodeToAsm, decodeBool, decodeInt, decodeString } from '@cashscript/utils'; import { findLastIndex, toRegExp } from './utils.js'; import { FailedRequireError, FailedTransactionError, FailedTransactionEvaluationError } from './Errors.js'; import { attributeLogEntry, buildCallStack, getActiveBytecode, resolveFrame } from './debug-frame.js'; import { getBitauthUri } from './libauth-template/LibauthTemplate.js'; +import { createVirtualMachine, VM } from './libauth-template/utils.js'; import { VmTarget } from './interfaces.js'; export type DebugResult = AuthenticationProgramStateCommon[]; export type DebugResults = Record; -/* eslint-disable @typescript-eslint/indent */ -type VM = AuthenticationVirtualMachine< - ResolvedTransactionCommon, - AuthenticationProgramCommon, - AuthenticationProgramStateCommon ->; -/* eslint-enable @typescript-eslint/indent */ - -const createVirtualMachine = (vmTarget: VmTarget): VM => { - switch (vmTarget) { - case 'BCH_2023_05': - return createVirtualMachineBch2023(); - case 'BCH_2025_05': - return createVirtualMachineBch2025(); - case 'BCH_2026_05': - return createVirtualMachineBch2026(); - case 'BCH_SPEC': - // TODO: This typecast is shitty, but it's hard to fix - return createVirtualMachineBchSpec() as unknown as VM; - default: - throw new Error(`Debugging is not supported for the ${vmTarget} virtual machine.`); - } -}; - // debugs the template, optionally logging the execution data export const debugTemplate = (template: WalletTemplate, artifacts: Artifact[]): DebugResults => { // If a contract has the same name, but a different bytecode, then it is considered a name collision diff --git a/packages/cashscript/src/interfaces.ts b/packages/cashscript/src/interfaces.ts index 009b672eb..793b02e23 100644 --- a/packages/cashscript/src/interfaces.ts +++ b/packages/cashscript/src/interfaces.ts @@ -10,9 +10,14 @@ export interface Utxo { vout: number; satoshis: bigint; token?: TokenDetails; + lockingBytecode?: string; } -export interface UnlockableUtxo extends Utxo { +export interface SpendableUtxo extends Utxo { + lockingBytecode: string; +} + +export interface UnlockableUtxo extends SpendableUtxo { unlocker: Unlocker; options?: InputOptions; } @@ -40,7 +45,6 @@ export interface GenerateUnlockingBytecodeOptions { } export interface Unlocker { - generateLockingBytecode: () => Uint8Array; generateUnlockingBytecode: (options: GenerateUnlockingBytecodeOptions) => Uint8Array; } @@ -56,7 +60,7 @@ export interface P2PKHUnlocker extends Unlocker { export type StandardUnlocker = ContractUnlocker | P2PKHUnlocker; -export type PlaceholderP2PKHUnlocker = Unlocker & { placeholder: true }; +export type PlaceholderP2PKHUnlocker = Unlocker & { placeholder: true, lockingBytecode: string }; export type ContractFunctionUnlocker = (...args: FunctionArgument[]) => ContractUnlocker; diff --git a/packages/cashscript/src/libauth-template/utils.ts b/packages/cashscript/src/libauth-template/utils.ts index 8c4d0a5c7..7e3f0bea2 100644 --- a/packages/cashscript/src/libauth-template/utils.ts +++ b/packages/cashscript/src/libauth-template/utils.ts @@ -1,6 +1,23 @@ import { AbiFunction, AbiInput, Artifact, formatBitAuthScript, sha256 } from '@cashscript/utils'; import { LibauthTokenDetails, SighashType, SignatureAlgorithm, TokenDetails, VmTarget } from '../interfaces.js'; -import { hexToBin, binToHex, isHex, decodeCashAddress, Input, assertSuccess, decodeAuthenticationInstructions, AuthenticationInstructionPush } from '@bitauth/libauth'; +import { + hexToBin, + binToHex, + isHex, + decodeCashAddress, + Input, + assertSuccess, + decodeAuthenticationInstructions, + AuthenticationInstructionPush, + AuthenticationProgramCommon, + AuthenticationProgramStateCommon, + AuthenticationVirtualMachine, + ResolvedTransactionCommon, + createVirtualMachineBch2023, + createVirtualMachineBch2025, + createVirtualMachineBch2026, + createVirtualMachineBchSpec, +} from '@bitauth/libauth'; import { EncodedFunctionArgument } from '../Argument.js'; import { zip } from '../utils.js'; import SignatureTemplate from '../SignatureTemplate.js'; @@ -8,6 +25,30 @@ import { Contract } from '../Contract.js'; export const DEFAULT_VM_TARGET = VmTarget.BCH_2026_05; +/* eslint-disable @typescript-eslint/indent */ +export type VM = AuthenticationVirtualMachine< + ResolvedTransactionCommon, + AuthenticationProgramCommon, + AuthenticationProgramStateCommon +>; +/* eslint-enable @typescript-eslint/indent */ + +export const createVirtualMachine = (vmTarget: VmTarget): VM => { + switch (vmTarget) { + case 'BCH_2023_05': + return createVirtualMachineBch2023(); + case 'BCH_2025_05': + return createVirtualMachineBch2025(); + case 'BCH_2026_05': + return createVirtualMachineBch2026(); + case 'BCH_SPEC': + // TODO: This typecast is shitty, but it's hard to fix + return createVirtualMachineBchSpec() as unknown as VM; + default: + throw new Error(`Evaluation is not supported for the ${vmTarget} virtual machine.`); + } +}; + export const getLockScriptName = (contract: Contract): string => { if (contract.contractType === 'p2s') { return `${contract.artifact.contractName}_${binToHex(sha256(hexToBin(contract.lockingBytecode)))}_lock`; diff --git a/packages/cashscript/src/network/ElectrumNetworkProvider.ts b/packages/cashscript/src/network/ElectrumNetworkProvider.ts index 432e014ff..f3e06a0a5 100644 --- a/packages/cashscript/src/network/ElectrumNetworkProvider.ts +++ b/packages/cashscript/src/network/ElectrumNetworkProvider.ts @@ -5,7 +5,7 @@ import { type RequestResponse, type ElectrumClientEvents, } from '@electrum-cash/network'; -import { Utxo, Network } from '../interfaces.js'; +import { SpendableUtxo, Network } from '../interfaces.js'; import NetworkProvider from './NetworkProvider.js'; import { addressToLockScript } from '../utils.js'; import { @@ -75,12 +75,12 @@ export default class ElectrumNetworkProvider implements NetworkProvider { } } - async getUtxos(address: string): Promise { + async getUtxos(address: string): Promise { const lockingBytecode = addressToLockScript(address); return this.getUtxosForLockingBytecode(lockingBytecode); } - async getUtxosForLockingBytecode(lockingBytecode: Uint8Array | string): Promise { + async getUtxosForLockingBytecode(lockingBytecode: Uint8Array | string): Promise { if (typeof lockingBytecode === 'string' && !isHex(lockingBytecode)) { throw new Error(`Invalid locking bytecode: ${lockingBytecode} is not a valid hex string`); } @@ -94,6 +94,7 @@ export default class ElectrumNetworkProvider implements NetworkProvider { txid: utxo.tx_hash, vout: utxo.tx_pos, satoshis: BigInt(utxo.value), + lockingBytecode: binToHex(lockingBytecodeBin), token: utxo.token_data ? { ...utxo.token_data, amount: BigInt(utxo.token_data.amount), diff --git a/packages/cashscript/src/network/MockNetworkProvider.ts b/packages/cashscript/src/network/MockNetworkProvider.ts index 0cb4d0e77..6fe888e54 100644 --- a/packages/cashscript/src/network/MockNetworkProvider.ts +++ b/packages/cashscript/src/network/MockNetworkProvider.ts @@ -1,9 +1,9 @@ -import { binToHex, decodeTransactionUnsafe, hexToBin, isHex } from '@bitauth/libauth'; +import { binToHex, decodeTransactionUnsafe, hexToBin, isHex, Transaction as LibauthTransaction } from '@bitauth/libauth'; import { sha256 } from '@cashscript/utils'; -import { Utxo, Network, VmTarget } from '../interfaces.js'; +import { SpendableUtxo, Utxo, Network, VmTarget } from '../interfaces.js'; import NetworkProvider from './NetworkProvider.js'; -import { addressToLockScript, libauthTokenDetailsToCashScriptTokenDetails } from '../utils.js'; -import { DEFAULT_VM_TARGET } from '../libauth-template/utils.js'; +import { addressToLockScript, cashScriptOutputToLibauthOutput, libauthTokenDetailsToCashScriptTokenDetails } from '../utils.js'; +import { createVirtualMachine, DEFAULT_VM_TARGET } from '../libauth-template/utils.js'; /** * Options accepted by the `MockNetworkProvider` constructor. @@ -15,7 +15,14 @@ export interface MockNetworkProviderOptions { * keep the UTXO set static. */ updateUtxoSet?: boolean; - /** The BCH VM target used for local debugging. Defaults to the current stable VM. */ + /** + * When `true` (default), broadcasting a transaction via `sendRawTransaction` evaluates it + * against the BCH VM using the *actual* locking bytecode of the spent UTXOs (like a real node + * would), rejecting invalid transactions. Requires `updateUtxoSet` to be enabled, since spent + * UTXOs are only looked up when the UTXO set is tracked. + */ + validateTransactions?: boolean; + /** The BCH VM target used for local debugging and transaction validation. Defaults to the current stable VM. */ vmTarget?: VmTarget; } @@ -26,7 +33,7 @@ export interface MockNetworkProviderOptions { */ export default class MockNetworkProvider implements NetworkProvider { // we use lockingBytecode hex as the key for utxoMap to make cash addresses and token addresses interchangeable - private utxoSet: Array<[string, Utxo]> = []; + private utxoSet: Array<[string, SpendableUtxo]> = []; private transactionMap: Record = {}; private blockHeight: number = 133700; public network: Network = Network.MOCKNET; @@ -40,16 +47,16 @@ export default class MockNetworkProvider implements NetworkProvider { * `TransactionBuilder.debug`. */ constructor(options?: Partial) { - this.options = { updateUtxoSet: true, ...options }; + this.options = { updateUtxoSet: true, validateTransactions: true, ...options }; this.vmTarget = this.options.vmTarget ?? DEFAULT_VM_TARGET; } - async getUtxos(address: string): Promise { + async getUtxos(address: string): Promise { const addressLockingBytecode = addressToLockScript(address); return this.getUtxosForLockingBytecode(addressLockingBytecode); } - async getUtxosForLockingBytecode(lockingBytecode: Uint8Array | string): Promise { + async getUtxosForLockingBytecode(lockingBytecode: Uint8Array | string): Promise { const lockingBytecodeHex = typeof lockingBytecode === 'string' ? lockingBytecode : binToHex(lockingBytecode); return this.utxoSet.filter(([key]) => key === lockingBytecodeHex).map(([, utxo]) => utxo); } @@ -81,25 +88,21 @@ export default class MockNetworkProvider implements NetworkProvider { return txid; } - this.transactionMap[txid] = txHex; - - // If updateUtxoSet is false, we don't need to update the utxo set, and just return the txid - if (!this.options.updateUtxoSet) return txid; + // If updateUtxoSet is false, we don't track spent UTXOs, so we cannot validate the transaction either + if (!this.options.updateUtxoSet) { + this.transactionMap[txid] = txHex; + return txid; + } const decodedTransaction = decodeTransactionUnsafe(transactionBin); + const spentUtxoEntries = this.findSpentUtxoEntries(decodedTransaction, txid); - decodedTransaction.inputs.forEach((input) => { - const utxoIndex = this.utxoSet.findIndex( - ([, utxo]) => utxo.txid === binToHex(input.outpointTransactionHash) && utxo.vout === input.outpointIndex, - ); - - // TODO: we should check what error a BCHN node throws, so we can throw the same error here - if (utxoIndex === -1) { - throw new Error(`UTXO not found for input ${input.outpointIndex} of transaction ${txid}`); - } + if (this.options.validateTransactions) { + this.validateTransaction(decodedTransaction, spentUtxoEntries); + } - this.utxoSet.splice(utxoIndex, 1); - }); + this.transactionMap[txid] = txHex; + this.utxoSet = this.utxoSet.filter((entry) => !spentUtxoEntries.includes(entry)); decodedTransaction.outputs.forEach((output, vout) => { this.addUtxo(binToHex(output.lockingBytecode), { @@ -113,6 +116,39 @@ export default class MockNetworkProvider implements NetworkProvider { return txid; } + private findSpentUtxoEntries(transaction: LibauthTransaction, txid: string): Array<[string, SpendableUtxo]> { + const remainingUtxoEntries = [...this.utxoSet]; + + return transaction.inputs.map((input) => { + const utxoIndex = remainingUtxoEntries.findIndex( + ([, utxo]) => utxo.txid === binToHex(input.outpointTransactionHash) && utxo.vout === input.outpointIndex, + ); + + // TODO: we should check what error a BCHN node throws, so we can throw the same error here + if (utxoIndex === -1) { + throw new Error(`UTXO not found for input ${input.outpointIndex} of transaction ${txid}`); + } + + return remainingUtxoEntries.splice(utxoIndex, 1)[0]; + }); + } + + // Evaluates the transaction against the BCH VM using the spent UTXOs (like a real node would) + private validateTransaction(transaction: LibauthTransaction, spentUtxoEntries: Array<[string, SpendableUtxo]>): void { + const sourceOutputs = spentUtxoEntries.map(([lockingBytecode, utxo]) => cashScriptOutputToLibauthOutput({ + to: hexToBin(lockingBytecode), + amount: utxo.satoshis, + token: utxo.token, + })); + + const vm = createVirtualMachine(this.vmTarget); + const verificationResult = vm.verify({ transaction, sourceOutputs }); + + if (verificationResult !== true) { + throw new Error(verificationResult); + } + } + // Note: the user can technically add the same UTXO multiple times (txid + vout), to the same or different addresses // but we don't check for this in the sendRawTransaction method. We might want to prevent duplicates from being added // in the first place. @@ -122,14 +158,15 @@ export default class MockNetworkProvider implements NetworkProvider { * * @param addressOrLockingBytecode - Either a CashAddress or a hex-encoded locking bytecode. * @param utxo - The UTXO to make spendable. - * @returns The added UTXO. + * @returns The added UTXO, annotated with the locking bytecode it was added under. */ - addUtxo(addressOrLockingBytecode: string, utxo: Utxo): Utxo { + addUtxo(addressOrLockingBytecode: string, utxo: Utxo): SpendableUtxo { const lockingBytecode = isHex(addressOrLockingBytecode) ? addressOrLockingBytecode : binToHex(addressToLockScript(addressOrLockingBytecode)); - this.utxoSet.push([lockingBytecode, utxo]); - return utxo; + const annotatedUtxo = { ...utxo, lockingBytecode }; + this.utxoSet.push([lockingBytecode, annotatedUtxo]); + return annotatedUtxo; } /** diff --git a/packages/cashscript/src/network/NetworkProvider.ts b/packages/cashscript/src/network/NetworkProvider.ts index e966982b4..62e95145e 100644 --- a/packages/cashscript/src/network/NetworkProvider.ts +++ b/packages/cashscript/src/network/NetworkProvider.ts @@ -1,4 +1,4 @@ -import { Utxo, Network } from '../interfaces.js'; +import { SpendableUtxo, Network } from '../interfaces.js'; export default interface NetworkProvider { /** @@ -11,14 +11,14 @@ export default interface NetworkProvider { * @param address The CashAddress for which we wish to retrieve UTXOs. * @returns List of UTXOs spendable by the provided address. */ - getUtxos(address: string): Promise; + getUtxos(address: string): Promise; /** * Retrieve all UTXOs (confirmed and unconfirmed) for a given locking bytecode. * @param lockingBytecode The locking bytecode for which we wish to retrieve UTXOs. * @returns List of UTXOs spendable by the provided locking bytecode. */ - getUtxosForLockingBytecode(lockingBytecode: Uint8Array | string): Promise; + getUtxosForLockingBytecode(lockingBytecode: Uint8Array | string): Promise; /** * @returns The current block height. diff --git a/packages/cashscript/src/transaction-utils.ts b/packages/cashscript/src/transaction-utils.ts index 9f4c7741e..1373ef2c6 100644 --- a/packages/cashscript/src/transaction-utils.ts +++ b/packages/cashscript/src/transaction-utils.ts @@ -5,8 +5,8 @@ import { isFungibleTokenUtxo, isNonTokenUtxo } from './utils.js'; * Result of `gatherBchUtxos` and `gatherFungibleTokenUtxos`: the selected UTXOs and the total * amount they cover (satoshis for BCH, token amount for fungible tokens). */ -export interface GatherUtxosResult { - utxos: Utxo[]; +export interface GatherUtxosResult { + utxos: U[]; totalAmount: bigint; } @@ -19,12 +19,12 @@ export interface GatherUtxosResult { * @returns The selected UTXOs and their cumulative satoshi amount. * @throws If the available non-token UTXOs do not cover the requested amount. */ -export function gatherBchUtxos(utxos: Utxo[], amount: bigint): GatherUtxosResult { +export function gatherBchUtxos(utxos: U[], amount: bigint): GatherUtxosResult { const sortedBchUtxos = utxos .filter(isNonTokenUtxo) .toSorted((a, b) => Number(b.satoshis - a.satoshis)); - const targetUtxos: Utxo[] = []; + const targetUtxos: U[] = []; let total = 0n; for (const utxo of sortedBchUtxos) { @@ -50,12 +50,14 @@ export function gatherBchUtxos(utxos: Utxo[], amount: bigint): GatherUtxosResult * @returns The selected UTXOs and their cumulative token amount. * @throws If the available fungible token UTXOs do not cover the requested amount. */ -export function gatherFungibleTokenUtxos(utxos: Utxo[], tokenCategory: string, amount: bigint): GatherUtxosResult { +export function gatherFungibleTokenUtxos( + utxos: U[], tokenCategory: string, amount: bigint, +): GatherUtxosResult { const sortedTokenUtxos = utxos .filter((utxo) => isFungibleTokenUtxo(utxo) && utxo.token!.category === tokenCategory) .toSorted((a, b) => Number(b.token!.amount - a.token!.amount)); - const targetUtxos: Utxo[] = []; + const targetUtxos: U[] = []; let total = 0n; for (const utxo of sortedTokenUtxos) { diff --git a/packages/cashscript/src/utils.ts b/packages/cashscript/src/utils.ts index d13b96cff..e8a2744bf 100644 --- a/packages/cashscript/src/utils.ts +++ b/packages/cashscript/src/utils.ts @@ -37,13 +37,20 @@ import { LibauthTokenDetails, ContractType, SighashType, + SpendableUtxo, + Unlocker, + isContractUnlocker, + isP2PKHUnlocker, + isPlaceholderUnlocker, } from './interfaces.js'; import { VERSION_SIZE, LOCKTIME_SIZE } from './constants.js'; import { OutputSatoshisTooSmallError, OutputTokenAmountTooSmallError, TokensToNonTokenAddressError, + InputMissingLockingBytecodeError, UndefinedInputError, + UnlockerLockingBytecodeMismatchError, OutputAddressNetworkMismatchError, OutputTokenCategoryInvalidError, OutputTokenCommitmentInvalidError, @@ -59,9 +66,57 @@ export function validateInput(utxo: Utxo, changeLocks: Record): throw new UndefinedInputError(); } + if (!utxo.lockingBytecode) { + throw new InputMissingLockingBytecodeError(); + } + validateChangeLocks(changeLocks, utxo.token?.category); } +// A UTXO/unlocker mismatch would be rejected by the network, so we catch it locally with a descriptive error +export function validateUnlocker(utxo: SpendableUtxo, unlocker: Unlocker, inputIndex: number, network: Network): void { + const unlockerLockingBytecode = getUnlockerLockingBytecode(unlocker); + if (unlockerLockingBytecode === undefined || utxo.lockingBytecode === unlockerLockingBytecode) return; + + throw new UnlockerLockingBytecodeMismatchError( + inputIndex, + formatLockingBytecode(utxo.lockingBytecode, network), + formatLockingBytecode(unlockerLockingBytecode, network), + describeUnlocker(unlocker), + ); +} + +// The locking bytecode this unlocker is able to unlock +function getUnlockerLockingBytecode(unlocker: Unlocker): string | undefined { + if (isContractUnlocker(unlocker)) return unlocker.contract.lockingBytecode; + if (isP2PKHUnlocker(unlocker)) return binToHex(publicKeyToP2PKHLockingBytecode(unlocker.template.publicKey)); + if (isPlaceholderUnlocker(unlocker)) return unlocker.lockingBytecode; + return undefined; +} + +function describeUnlocker(unlocker: Unlocker): string { + if (isContractUnlocker(unlocker)) { + return `unlocker for function "${unlocker.abiFunction.name}" of contract "${unlocker.contract.name}"`; + } + + if (isP2PKHUnlocker(unlocker)) { + return `P2PKH unlocker for public key ${binToHex(unlocker.template.publicKey)}`; + } + + if (isPlaceholderUnlocker(unlocker)) { + return 'placeholder P2PKH unlocker'; + } + + return 'custom unlocker'; +} + +function formatLockingBytecode(lockingBytecode: string, network: Network): string { + const prefix = getNetworkPrefix(network); + const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecode), prefix }); + if (typeof result === 'string') return `locking bytecode ${lockingBytecode}`; + return `address ${result.address} (locking bytecode ${lockingBytecode})`; +} + export function validateOutput(output: Output, network: Network, changeLocks: Record): void { validateChangeLocks(changeLocks, output.token?.category); @@ -179,7 +234,7 @@ export function generateLibauthSourceOutputs(inputs: UnlockableUtxo[]): LibauthO const sourceOutputs = inputs.map((input) => { const sourceOutput = { amount: input.satoshis, - to: input.unlocker.generateLockingBytecode(), + to: hexToBin(input.lockingBytecode), token: input.token, }; @@ -371,14 +426,12 @@ const randomInt = (): bigint => BigInt(Math.floor(Math.random() * 10000)); * @param defaults - Values that override the randomly generated fields. * @returns A synthetic UTXO with random `txid`, `vout`, and `satoshis`. */ -export const randomUtxo = (defaults?: Partial): Utxo => ({ - ...{ - txid: binToHex(sha256(bigIntToVmNumber(randomInt()))), - vout: Math.floor(Math.random() * 10), - satoshis: 100_000n + randomInt(), - }, +export const randomUtxo = >(defaults?: T): Utxo & T => ({ + txid: binToHex(sha256(bigIntToVmNumber(randomInt()))), + vout: Math.floor(Math.random() * 10), + satoshis: 100_000n + randomInt(), ...defaults, -}); +} as Utxo & T); /** * Generate random fungible `TokenDetails` for use in tests and examples. Fields can be overridden diff --git a/packages/cashscript/src/walletconnect-utils.ts b/packages/cashscript/src/walletconnect-utils.ts index d4c7cd3a2..140ab3427 100644 --- a/packages/cashscript/src/walletconnect-utils.ts +++ b/packages/cashscript/src/walletconnect-utils.ts @@ -1,6 +1,6 @@ import { type LibauthOutput, isContractUnlocker, type PlaceholderP2PKHUnlocker, type UnlockableUtxo } from './interfaces.js'; import { type AbiFunction, type Artifact } from '@cashscript/utils'; -import { cashAddressToLockingBytecode, hexToBin, type Input, type TransactionCommon } from '@bitauth/libauth'; +import { binToHex, cashAddressToLockingBytecode, hexToBin, type Input, type TransactionCommon } from '@bitauth/libauth'; // Wallet Connect interfaces according to the spec // see https://github.com/mainnet-pat/wc2-bch-bcr @@ -80,10 +80,9 @@ export const placeholderP2PKHUnlocker = (userAddress: string): PlaceholderP2PKHU throw new Error(`Invalid address: ${decodeAddressResult}`); } - const lockingBytecode = decodeAddressResult.bytecode; return { - generateLockingBytecode: () => lockingBytecode, generateUnlockingBytecode: () => Uint8Array.from(Array(0)), placeholder: true, + lockingBytecode: binToHex(decodeAddressResult.bytecode), }; }; diff --git a/packages/cashscript/test/Contract.test.ts b/packages/cashscript/test/Contract.test.ts index 0125f070b..bd9c39b41 100644 --- a/packages/cashscript/test/Contract.test.ts +++ b/packages/cashscript/test/Contract.test.ts @@ -182,8 +182,8 @@ describe('Contract', () => { }); it('generates correct locking bytecode', () => { - expect(instance.unlock.spend(alicePub, new SignatureTemplate(alicePriv)).generateLockingBytecode()) - .toEqual(hexToBin('aa2034d9ffce86b4d136ca74e9db6f6433d3548966a6be064052e728a4c1d16aa3a587')); + expect(instance.lockingBytecode) + .toEqual('aa2034d9ffce86b4d136ca74e9db6f6433d3548966a6be064052e728a4c1d16aa3a587'); }); it('can spend from a p2s contract', async () => { @@ -205,6 +205,7 @@ describe('Contract', () => { txid: 'e5ac1aa9730d7514b541895e466c987327a4b0c57fcbbd50fc73788f5c0f65d9', vout: 4, satoshis: 102745n, + lockingBytecode: instance.lockingBytecode, }; const unlocker = instance.unlock.spend(alicePub, new SignatureTemplate(alicePriv)); diff --git a/packages/cashscript/test/SignatureTemplate.test.ts b/packages/cashscript/test/SignatureTemplate.test.ts index c21f9588f..24fd070bf 100644 --- a/packages/cashscript/test/SignatureTemplate.test.ts +++ b/packages/cashscript/test/SignatureTemplate.test.ts @@ -69,13 +69,12 @@ describe('SignatureTemplate', () => { txid: '043ec3826702c45460a6dd6b13e343a8f1bc06bc047b63ca484f791dfdfd92c2', vout: 8, satoshis: 109759n, + lockingBytecode: '76a914512dbb2c8c02efbac8d92431aa0ac33f6b0bf97088ac', }; const signatureTemplate = new SignatureTemplate(alicePriv); const unlocker = signatureTemplate.unlockP2PKH(); - expect(unlocker.generateLockingBytecode()).toEqual(hexToBin('76a914512dbb2c8c02efbac8d92431aa0ac33f6b0bf97088ac')); - const transactionBuilder = new TransactionBuilder({ provider: new MockNetworkProvider() }) .addInput(utxo, unlocker) .addOutput({ to: aliceAddress, amount: 1000n }); diff --git a/packages/cashscript/test/TransactionBuilder.test.ts b/packages/cashscript/test/TransactionBuilder.test.ts index bb81be5b7..c07c4e5a3 100644 --- a/packages/cashscript/test/TransactionBuilder.test.ts +++ b/packages/cashscript/test/TransactionBuilder.test.ts @@ -14,7 +14,7 @@ import { carolTokenAddress, alicePriv, } from './fixture/vars.js'; -import { Network } from '../src/interfaces.js'; +import { Network, SpendableUtxo, Utxo } from '../src/interfaces.js'; import { utxoComparator, calculateDust, randomUtxo, randomToken, isNonTokenUtxo, isFungibleTokenUtxo } from '../src/utils.js'; import p2pkhArtifact from './fixture/p2pkh.artifact.js'; import twtArtifact from './fixture/transfer_with_timeout.artifact.js'; @@ -22,9 +22,11 @@ import { TransactionBuilder } from '../src/TransactionBuilder.js'; import { addUtxo, getTxOutputs } from './test-util.js'; import { generateWcTransactionObjectFixture } from './fixture/walletconnect/fixtures.js'; import { + InputMissingLockingBytecodeError, OutputBchChangeLockedError, OutputTokenChangeLockedError, TokensToNonTokenAddressError, + UnlockerLockingBytecodeMismatchError, } from '../src/Errors.js'; import { FailingMockNetworkProvider } from '../src/network/MockNetworkProvider.js'; @@ -223,6 +225,45 @@ describe('Transaction Builder', () => { }); }); + describe('UTXO / unlocker mismatch checks', () => { + it('should fail when spending a contract UTXO with an unlocker of a different contract', async () => { + const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo); + + expect(() => ( + new TransactionBuilder({ provider }) + .addInput(p2pkhUtxos[0], twtInstance.unlock.transfer(new SignatureTemplate(carolPriv))) + )).toThrow(UnlockerLockingBytecodeMismatchError); + }); + + it('should fail when spending a contract UTXO with an unlocker of the same contract with a different address type', async () => { + const p2sh20Instance = new Contract(p2pkhArtifact, [carolPkh], { provider, contractType: 'p2sh20' }); + const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo); + + expect(() => ( + new TransactionBuilder({ provider }) + .addInput(p2pkhUtxos[0], p2sh20Instance.unlock.spend(carolPub, new SignatureTemplate(carolPriv))) + )).toThrow(UnlockerLockingBytecodeMismatchError); + }); + + it('should fail when spending a P2PKH UTXO with a SignatureTemplate for a different key', async () => { + const aliceUtxos = (await provider.getUtxos(aliceAddress)).filter(isNonTokenUtxo); + + expect(() => ( + new TransactionBuilder({ provider }) + .addInput(aliceUtxos[0], new SignatureTemplate(bobPriv).unlockP2PKH()) + )).toThrow(UnlockerLockingBytecodeMismatchError); + }); + + it('should fail when adding a UTXO without a lockingBytecode', async () => { + const utxoWithoutLockingBytecode = randomUtxo() as SpendableUtxo; + + expect(() => ( + new TransactionBuilder({ provider }) + .addInput(utxoWithoutLockingBytecode, new SignatureTemplate(bobPriv).unlockP2PKH()) + )).toThrow(InputMissingLockingBytecodeError); + }); + }); + describe('test TransactionBuilder.generateWcTransactionObject', () => { it('should match the generateWcTransactionObjectFixture ', async () => { const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse(); @@ -304,7 +345,7 @@ describe('Transaction Builder', () => { it('should preserve the Bitauth URI when broadcast fails', async () => { const failingProvider = new FailingMockNetworkProvider(); const contract = new Contract(p2pkhArtifact, [carolPkh], { provider: failingProvider }); - const utxo = randomUtxo({ satoshis: 100_000n }); + const utxo = failingProvider.addUtxo(contract.address, randomUtxo({ satoshis: 100_000n })); const transaction = new TransactionBuilder({ provider: failingProvider }) .addInput(utxo, contract.unlock.spend(carolPub, new SignatureTemplate(carolPriv))) @@ -356,14 +397,18 @@ describe('Transaction Builder', () => { p2pkhInstance.unlock.spend(carolPub, new SignatureTemplate(carolPriv)) ); + const randomContractUtxo = (defaults?: Partial): SpendableUtxo => ( + randomUtxo({ ...defaults, lockingBytecode: p2pkhInstance.lockingBytecode }) + ); + describe('BCH change lock', () => { it('should prevent further inputs or outputs after a BCH change output was added', () => { const builder = new TransactionBuilder({ provider }) - .addInput(randomUtxo(), carolUnlocker()) + .addInput(randomContractUtxo(), carolUnlocker()) .addOutput({ to: bobAddress, amount: 1000n }) .addBchChangeOutputIfNeeded({ to: aliceAddress, feeRate: 1.0 }); - expect(() => builder.addInput(randomUtxo(), carolUnlocker())).toThrow(OutputBchChangeLockedError); + expect(() => builder.addInput(randomContractUtxo(), carolUnlocker())).toThrow(OutputBchChangeLockedError); expect(() => builder.addOutput({ to: bobAddress, amount: 1000n })).toThrow(OutputBchChangeLockedError); expect(() => builder.addOpReturnOutput(['hello'])).toThrow(OutputBchChangeLockedError); }); @@ -371,7 +416,7 @@ describe('Transaction Builder', () => { it('should still lock when no change output was added because the surplus would be dust', () => { // Output leaves only a few satoshis of surplus, well below dust — no change output is added const builder = new TransactionBuilder({ provider }) - .addInput(randomUtxo({ satoshis: 2_000n }), carolUnlocker()) + .addInput(randomContractUtxo({ satoshis: 2_000n }), carolUnlocker()) .addOutput({ to: bobAddress, amount: 1_500n }); const outputCountBefore = builder.outputs.length; builder.addBchChangeOutputIfNeeded({ to: aliceAddress, feeRate: 1.0 }); @@ -385,10 +430,10 @@ describe('Transaction Builder', () => { it('should prevent further inputs or outputs of the same category after a token change output was added', () => { const token = randomToken(); const builder = new TransactionBuilder({ provider }) - .addInput(randomUtxo({ token }), carolUnlocker()) + .addInput(randomContractUtxo({ token }), carolUnlocker()) .addTokenChangeOutputIfNeeded({ category: token.category, to: aliceTokenAddress }); - expect(() => builder.addInput(randomUtxo({ token }), carolUnlocker())).toThrow(OutputTokenChangeLockedError); + expect(() => builder.addInput(randomContractUtxo({ token }), carolUnlocker())).toThrow(OutputTokenChangeLockedError); expect(() => builder.addOutput({ to: bobTokenAddress, amount: 1000n, token: { amount: 100n, category: token.category }, })).toThrow(OutputTokenChangeLockedError); @@ -398,13 +443,13 @@ describe('Transaction Builder', () => { const tokenA = randomToken(); const tokenB = randomToken(); const builder = new TransactionBuilder({ provider }) - .addInput(randomUtxo({ token: tokenA }), carolUnlocker()) + .addInput(randomContractUtxo({ token: tokenA }), carolUnlocker()) .addTokenChangeOutputIfNeeded({ category: tokenA.category, to: aliceTokenAddress }); expect(() => { - builder.addInput(randomUtxo({ token: tokenB }), carolUnlocker()); + builder.addInput(randomContractUtxo({ token: tokenB }), carolUnlocker()); builder.addOutput({ to: bobTokenAddress, amount: 1000n, token: { amount: 100n, category: tokenB.category } }); - builder.addInput(randomUtxo(), carolUnlocker()); + builder.addInput(randomContractUtxo(), carolUnlocker()); builder.addOutput({ to: bobAddress, amount: 1000n }); builder.addOpReturnOutput(['hello']); }).not.toThrow(); @@ -416,7 +461,7 @@ describe('Transaction Builder', () => { const token = randomToken({ amount: 1000n }); const tx = new TransactionBuilder({ provider }) - .addInput(randomUtxo({ token }), carolUnlocker()) + .addInput(randomContractUtxo({ token }), carolUnlocker()) .addOutput({ to: bobTokenAddress, amount: 1000n, token: { amount: 400n, category: token.category } }) .addTokenChangeOutputIfNeeded({ category: token.category, to: aliceTokenAddress }) .build(); @@ -431,14 +476,14 @@ describe('Transaction Builder', () => { it('should lock the category without adding an output when no change is needed', () => { const token = randomToken({ amount: 1000n }); const builder = new TransactionBuilder({ provider }) - .addInput(randomUtxo({ token }), carolUnlocker()) + .addInput(randomContractUtxo({ token }), carolUnlocker()) // Match input amount with an explicit output so there's no surplus .addOutput({ to: bobTokenAddress, amount: 1000n, token: { amount: 1000n, category: token.category } }); const outputCountBefore = builder.outputs.length; builder.addTokenChangeOutputIfNeeded({ category: token.category, to: aliceTokenAddress }); expect(builder.outputs.length).toBe(outputCountBefore); - expect(() => builder.addInput(randomUtxo({ token }), carolUnlocker())).toThrow(OutputTokenChangeLockedError); + expect(() => builder.addInput(randomContractUtxo({ token }), carolUnlocker())).toThrow(OutputTokenChangeLockedError); }); it('should scope the change output to the configured category across multiple invocations', () => { @@ -446,8 +491,8 @@ describe('Transaction Builder', () => { const tokenB = randomToken({ amount: 5000n }); const tx = new TransactionBuilder({ provider }) - .addInput(randomUtxo({ token: tokenA }), carolUnlocker()) - .addInput(randomUtxo({ token: tokenB }), carolUnlocker()) + .addInput(randomContractUtxo({ token: tokenA }), carolUnlocker()) + .addInput(randomContractUtxo({ token: tokenB }), carolUnlocker()) .addOutput({ to: bobTokenAddress, amount: 1000n, token: { amount: 700n, category: tokenA.category } }) .addOutput({ to: bobTokenAddress, amount: 1000n, token: { amount: 2000n, category: tokenB.category } }) .addTokenChangeOutputIfNeeded({ category: tokenA.category, to: aliceTokenAddress }) @@ -464,7 +509,7 @@ describe('Transaction Builder', () => { it('should fail when the change address does not support tokens', () => { const token = randomToken(); const builder = new TransactionBuilder({ provider }) - .addInput(randomUtxo({ token }), carolUnlocker()); + .addInput(randomContractUtxo({ token }), carolUnlocker()); expect(() => { builder.addTokenChangeOutputIfNeeded({ category: token.category, to: aliceAddress }); @@ -474,7 +519,7 @@ describe('Transaction Builder', () => { it('should fail when a BCH change output was already added', () => { const token = randomToken(); const builder = new TransactionBuilder({ provider }) - .addInput(randomUtxo({ token }), carolUnlocker()) + .addInput(randomContractUtxo({ token }), carolUnlocker()) .addBchChangeOutputIfNeeded({ to: aliceAddress, feeRate: 1.0 }); expect(() => { diff --git a/packages/cashscript/test/debugging.test.ts b/packages/cashscript/test/debugging.test.ts index e023c6666..36fe4f473 100644 --- a/packages/cashscript/test/debugging.test.ts +++ b/packages/cashscript/test/debugging.test.ts @@ -1,4 +1,4 @@ -import { Contract, FailedTransactionError, MockNetworkProvider, SignatureAlgorithm, SignatureTemplate, TransactionBuilder, VmTarget } from '../src/index.js'; +import { Contract, MockNetworkProvider, SignatureAlgorithm, SignatureTemplate, TransactionBuilder, UnlockerLockingBytecodeMismatchError, VmTarget } from '../src/index.js'; import { DEFAULT_VM_TARGET, getLockScriptName } from '../src/libauth-template/utils.js'; import { aliceAddress, alicePriv, alicePub, bobPriv, bobPub } from './fixture/vars.js'; import { randomUtxo } from '../src/utils.js'; @@ -177,7 +177,7 @@ describe('Debugging tests', () => { it('should log inside a loop', async () => { const transaction = new TransactionBuilder({ provider }) - .addInput(contractUtxo, contractTestLogInsideLoop.unlock.test_log_inside_loop()) + .addInput(contractTestLogInsideLoopUtxo, contractTestLogInsideLoop.unlock.test_log_inside_loop()) .addOutput({ to: contractTestLogInsideLoop.address, amount: 10000n }); expect(transaction).toLog(new RegExp('^\\[Input #0] Test.cash:6 i: 0$')); @@ -751,18 +751,16 @@ describe('Debugging tests', () => { expect(Object.keys(result).length).toBeGreaterThan(0); }); - // We currently don't have a way to properly handle non-matching UTXOs and unlockers - // Note: that also goes for Contract UTXOs where a user uses an unlocker of a different contract - it.skip('should fail when spending from P2PKH inputs with an unlocker for a different public key', async () => { + it('should fail when spending from P2PKH inputs with an unlocker for a different public key', async () => { const provider = new MockNetworkProvider(); provider.addUtxo(aliceAddress, randomUtxo()); provider.addUtxo(aliceAddress, randomUtxo()); - const transactionBuilder = new TransactionBuilder({ provider }) - .addInputs(await provider.getUtxos(aliceAddress), new SignatureTemplate(bobPriv).unlockP2PKH()) - .addOutput({ to: aliceAddress, amount: 5000n }); + const utxos = await provider.getUtxos(aliceAddress); - expect(() => transactionBuilder.debug()).toThrow(FailedTransactionError); + expect(() => ( + new TransactionBuilder({ provider }).addInputs(utxos, new SignatureTemplate(bobPriv).unlockP2PKH()) + )).toThrow(UnlockerLockingBytecodeMismatchError); }); }); diff --git a/packages/cashscript/test/e2e/MultiContract.test.ts b/packages/cashscript/test/e2e/MultiContract.test.ts index 734941962..2004cbf26 100644 --- a/packages/cashscript/test/e2e/MultiContract.test.ts +++ b/packages/cashscript/test/e2e/MultiContract.test.ts @@ -18,7 +18,7 @@ import { carolPriv, carolPub, } from '../fixture/vars.js'; -import { Network, Utxo } from '../../src/interfaces.js'; +import { Network, SpendableUtxo } from '../../src/interfaces.js'; import { addressToLockScript, randomUtxo } from '../../src/utils.js'; import p2pkhArtifact from '../fixture/p2pkh.artifact.js'; import twtArtifact from '../fixture/transfer_with_timeout.artifact.js'; @@ -214,9 +214,9 @@ describe('Multi Contract', () => { const correctLockingBytecode = addressToLockScript(correctContract.address); const siblingIntrospectionContract = new Contract(SiblingIntrospectionArtifact, [correctLockingBytecode], { provider }); - let correctContractUtxo: Utxo; - let incorrectContractUtxo: Utxo; - let siblingIntrospectionUtxo: Utxo; + let correctContractUtxo: SpendableUtxo; + let incorrectContractUtxo: SpendableUtxo; + let siblingIntrospectionUtxo: SpendableUtxo; beforeAll(async () => { correctContractUtxo = await addUtxo(provider, correctContract.address, randomUtxo()); diff --git a/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts b/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts index 75198caa8..756d56182 100644 --- a/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts +++ b/packages/cashscript/test/e2e/network/MockNetworkProvider.test.ts @@ -9,6 +9,7 @@ import { alicePriv, alicePub, bobAddress, + bobPriv, } from '../../fixture/vars.js'; describe.skipIf(Boolean(process.env.TESTS_USE_CHIPNET))('MockNetworkProvider', () => { @@ -72,6 +73,58 @@ describe.skipIf(Boolean(process.env.TESTS_USE_CHIPNET))('MockNetworkProvider', ( }); }); + describe('transaction validation', () => { + const provider = new MockNetworkProvider(); + + beforeEach(() => { + provider.reset(); + }); + + it('should annotate UTXOs with the locking bytecode they were added under', async () => { + const aliceLockingBytecode = binToHex(addressToLockScript(aliceAddress)); + const addedUtxo = provider.addUtxo(aliceAddress, randomUtxo()); + + expect(addedUtxo.lockingBytecode).toBe(aliceLockingBytecode); + + const fetchedUtxos = await provider.getUtxos(aliceAddress); + expect(fetchedUtxos[0].lockingBytecode).toBe(aliceLockingBytecode); + }); + + it('should reject a transaction that spends a UTXO with a mismatched unlocker', async () => { + // We deliberately set the lockingBytecode to bob's lock script so the TransactionBuilder + // mismatch check does not trigger, allowing us to build a transaction that spends alice's + // UTXO with bob's key + const bobLockingBytecode = binToHex(addressToLockScript(bobAddress)); + const utxo = { ...provider.addUtxo(aliceAddress, randomUtxo()), lockingBytecode: bobLockingBytecode }; + + const transaction = new TransactionBuilder({ provider }) + .addInput(utxo, new SignatureTemplate(bobPriv).unlockP2PKH()) + .addOutput({ to: aliceAddress, amount: 5000n }) + .build(); + + await expect(provider.sendRawTransaction(transaction)).rejects.toThrow(); + + // the failed transaction should not have updated the utxo set + expect(await provider.getUtxos(aliceAddress)).toHaveLength(1); + }); + + it('should accept invalid transactions when validateTransactions is set to false', async () => { + const nonValidatingProvider = new MockNetworkProvider({ validateTransactions: false }); + const bobLockingBytecode = binToHex(addressToLockScript(bobAddress)); + const utxo = { + ...nonValidatingProvider.addUtxo(aliceAddress, randomUtxo()), + lockingBytecode: bobLockingBytecode, + }; + + const transaction = new TransactionBuilder({ provider: nonValidatingProvider }) + .addInput(utxo, new SignatureTemplate(bobPriv).unlockP2PKH()) + .addOutput({ to: aliceAddress, amount: 5000n }) + .build(); + + await expect(nonValidatingProvider.sendRawTransaction(transaction)).resolves.toBeTruthy(); + }); + }); + describe('when updateUtxoSet is set to false', () => { const provider = new MockNetworkProvider({ updateUtxoSet: false }); diff --git a/packages/cashscript/test/fixture/libauth-template/fixtures.ts b/packages/cashscript/test/fixture/libauth-template/fixtures.ts index 0a145f3b2..84684c0f9 100644 --- a/packages/cashscript/test/fixture/libauth-template/fixtures.ts +++ b/packages/cashscript/test/fixture/libauth-template/fixtures.ts @@ -3,7 +3,7 @@ import TransferWithTimeout from '../transfer_with_timeout.artifact.js'; import Mecenas from '../mecenas.artifact.js'; import P2PKH from '../p2pkh.artifact.js'; import HoldVault from '../hodl_vault.artifact.js'; -import { aliceAddress, alicePkh, alicePriv, alicePub, bobPkh, bobPriv, bobPub, oracle, oraclePub } from '../vars.js'; +import { aliceAddress, alicePkh, alicePriv, alicePub, bobAddress, bobPkh, bobPriv, bobPub, oracle, oraclePub } from '../vars.js'; import { WalletTemplate, hexToBin } from '@bitauth/libauth'; const provider = new MockNetworkProvider(); @@ -1290,6 +1290,7 @@ export const fixtures: Fixture[] = [ const contractUtxo = provider.addUtxo(contract.address, randomUtxo()); const p2pkhUtxo = provider.addUtxo(aliceAddress, randomUtxo()); + const bobP2pkhUtxo = provider.addUtxo(bobAddress, randomUtxo()); const to = contract.tokenAddress; const amount = 1000n; @@ -1301,7 +1302,7 @@ export const fixtures: Fixture[] = [ const tx = new TransactionBuilder({ provider }) .addInput(p2pkhUtxo, aliceDefaultTemplate.unlockP2PKH()) .addInput(contractUtxo, contract.unlock.spend(alicePub, aliceCustomTemplate)) - .addInput(p2pkhUtxo, bobCustomTemplate.unlockP2PKH()) + .addInput(bobP2pkhUtxo, bobCustomTemplate.unlockP2PKH()) .addOutput({ to, amount }); return tx; diff --git a/packages/cashscript/test/test-util.ts b/packages/cashscript/test/test-util.ts index a809a68d7..e29c158e2 100644 --- a/packages/cashscript/test/test-util.ts +++ b/packages/cashscript/test/test-util.ts @@ -6,9 +6,9 @@ import { } from '@bitauth/libauth'; import PQueue from 'p-queue'; import pRetry from 'p-retry'; -import { Output, Network, Utxo } from '../src/interfaces.js'; +import { Output, Network, SpendableUtxo, Utxo } from '../src/interfaces.js'; import { network as defaultNetwork, funderAddress, funderPriv } from './fixture/vars.js'; -import { getNetworkPrefix, isNonTokenUtxo, libauthOutputToCashScriptOutput } from '../src/utils.js'; +import { addressToLockScript, getNetworkPrefix, isNonTokenUtxo, libauthOutputToCashScriptOutput } from '../src/utils.js'; import { utxoComparator } from '../src/utils.js'; import MockNetworkProvider from '../src/network/MockNetworkProvider.js'; import NetworkProvider from '../src/network/NetworkProvider.js'; @@ -38,7 +38,7 @@ export function getTxOutputs(tx: Transaction, network: Network = defaultNetwork) }); } -export function getLargestUtxo(utxos: Utxo[]): Utxo { +export function getLargestUtxo(utxos: U[]): U { return [...utxos].sort(utxoComparator).reverse()[0]; } @@ -59,7 +59,7 @@ export async function addUtxo( provider: NetworkProvider, address: string, utxo: Utxo, -): Promise { +): Promise { if (provider instanceof MockNetworkProvider) { return provider.addUtxo(address, utxo); } @@ -89,7 +89,7 @@ async function sendLiveAddUtxo( provider: NetworkProvider, address: string, utxo: Utxo, -): Promise { +): Promise { const funderUtxos = (await provider.getUtxos(funderAddress)) .filter(isNonTokenUtxo) .sort(utxoComparator) @@ -107,14 +107,15 @@ async function sendLiveAddUtxo( txid: tx.txid, vout: 0, satoshis: utxo.satoshis, + lockingBytecode: binToHex(addressToLockScript(address)), }; } -export function gatherUtxos( - utxos: Utxo[], +export function gatherUtxos( + utxos: U[], options?: { amount?: bigint, fee?: bigint }, -): { utxos: Utxo[], total: bigint, changeAmount: bigint } { - const targetUtxos: Utxo[] = []; +): { utxos: U[], total: bigint, changeAmount: bigint } { + const targetUtxos: U[] = []; let total = 0n; // 1000 for fees diff --git a/packages/cashscript/test/types/Contract.types.test.ts b/packages/cashscript/test/types/Contract.types.test.ts index 0eb6d8f2f..d94bc1244 100644 --- a/packages/cashscript/test/types/Contract.types.test.ts +++ b/packages/cashscript/test/types/Contract.types.test.ts @@ -119,7 +119,7 @@ const provider = new MockNetworkProvider(); const contract = new Contract(p2pkhArtifact, [alicePkh], { provider }); // it('should not give type errors when using correct function inputs') - contract.unlock.spend(alicePub, new SignatureTemplate(alicePriv)).generateLockingBytecode(); + contract.unlock.spend(alicePub, new SignatureTemplate(alicePriv)).generateUnlockingBytecode; // it('should give type errors when calling a function that does not exist') // @ts-expect-error @@ -137,14 +137,14 @@ const provider = new MockNetworkProvider(); // it('should not perform type checking when cast to any') const contractAsAny = new Contract(p2pkhArtifact as any, [alicePkh, 1000n], { provider }); - contractAsAny.unlock.notAFunction().generateLockingBytecode(); + contractAsAny.unlock.notAFunction().generateUnlockingBytecode; contractAsAny.unlock.spend(); contractAsAny.unlock.spend(1000n, true); // it('should not perform type checking when cannot infer type') // Note: would be very nice if it *could* infer the type from static json const contractFromUnknown = new Contract(p2pkhArtifactJsonNotConst, [alicePkh, 1000n], { provider }); - contractFromUnknown.unlock.notAFunction().generateLockingBytecode(); + contractFromUnknown.unlock.notAFunction().generateUnlockingBytecode; contractFromUnknown.unlock.spend(); contractFromUnknown.unlock.spend(1000n, true); diff --git a/website/docs/guides/cashtokens.md b/website/docs/guides/cashtokens.md index 0c0393af0..fefd2e2c9 100644 --- a/website/docs/guides/cashtokens.md +++ b/website/docs/guides/cashtokens.md @@ -20,6 +20,7 @@ interface Utxo { vout: number; satoshis: bigint; token?: TokenDetails; + lockingBytecode?: string; } interface TokenDetails { diff --git a/website/docs/guides/optimization.md b/website/docs/guides/optimization.md index e19e0b09f..8252f7c02 100644 --- a/website/docs/guides/optimization.md +++ b/website/docs/guides/optimization.md @@ -184,14 +184,13 @@ You can create an `Artifact` for a fully hand-written contract so it becomes pos In the [addInput() method][addInput()] on the TransactionBuilder you can provide a custom `Unlocker` ```ts -transactionBuilder.addInput(utxo: Utxo, unlocker: Unlocker, options?: InputOptions): this +transactionBuilder.addInput(utxo: SpendableUtxo, unlocker: Unlocker, options?: InputOptions): this ``` the `Unlocker` interface is the following: ```ts interface Unlocker { - generateLockingBytecode: () => Uint8Array; generateUnlockingBytecode: (options: GenerateUnlockingBytecodeOptions) => Uint8Array; } diff --git a/website/docs/releases/migration-notes.md b/website/docs/releases/migration-notes.md index d981afe46..e182753af 100644 --- a/website/docs/releases/migration-notes.md +++ b/website/docs/releases/migration-notes.md @@ -44,6 +44,22 @@ const signatureTemplate = new SignatureTemplate(wif, HashType.SIGHASH_ALL | Hash const signatureTemplate = new SignatureTemplate(wif, SighashType.SIGHASH_ALL | SighashType.SIGHASH_UTXOS); ``` +#### UTXOs must include their locking bytecode + +UTXOs returned by network providers now include a `lockingBytecode` field with the actual locking bytecode of the UTXO, and input UTXOs passed to the `TransactionBuilder` are required to include this field. + +If you fetch UTXOs from a standard network provider, no code changes are needed. If you construct UTXOs manually (e.g. from your own indexer or persisted data), you need to add the `lockingBytecode` field: + +```ts +// before +const utxo = { txid, vout, satoshis }; + +// after +const utxo = { txid, vout, satoshis, lockingBytecode }; +``` + +Since the spent UTXO's `lockingBytecode` is now the source of truth for the locking script, the `generateLockingBytecode()` method was removed from the `Unlocker` interface. If you implement custom unlockers, remove the `generateLockingBytecode()` method from your implementation. + ## v0.12 to v0.13 ### cashc compiler diff --git a/website/docs/releases/release-notes.md b/website/docs/releases/release-notes.md index d876754e6..45b3e90ab 100644 --- a/website/docs/releases/release-notes.md +++ b/website/docs/releases/release-notes.md @@ -4,7 +4,7 @@ title: Release Notes ## v0.14.0-next.4 -⚠️ Note that this is a pre-release version and is not yet stable. There will likely be breaking changes to the APIs and compiler output in subsequent pre-releases. +This release contains several breaking changes, please refer to the [migration notes](/docs/releases/migration-notes) for more information. #### cashc compiler - :sparkles: Add support for user-defined reusable functions. @@ -25,14 +25,19 @@ title: Release Notes #### CashScript SDK - :sparkles: Add support for debugging user-defined functions. - :sparkles: Add stack trace when debugging failed requires inside nested functions. +- :sparkles: Add a `validateTransactions` option (default: `true`) to the `MockNetworkProvider` to validate sent transactions against the BCH VM using the actual locking bytecode of the spent UTXOs. +- :hammer_and_wrench: Add `lockingBytecode` field to the `Utxo` interface, set automatically on all UTXOs returned by network providers. - :hammer_and_wrench: **BREAKING**: Replace the `SignatureTemplate`'s `getHashType()`, `getPublicKey()` and `getSignatureAlgorithm()` methods with the `sighashType`, `publicKey` and `signatureAlgorithm` properties. - :hammer_and_wrench: **BREAKING**: Remove the `bchForkId` parameter from `SignatureTemplate`'s `generateSignature()` method, since BCH consensus rules always require the fork ID flag. - :hammer_and_wrench: **BREAKING**: Rename the `HashType` enum to `SighashType`. +- :hammer_and_wrench: **BREAKING**: The `TransactionBuilder` now requires UTXOs to include their `lockingBytecode`, and validates it against the provided unlocker. +- :boom: **BREAKING**: Remove `generateLockingBytecode()` from the `Unlocker` interface. + ## v0.13.3 #### CashScript SDK -- :bug: Fix issue where `getTransactionSize()` undersized inputs when using `placeholderP2PKHUnlocker()` +- :bug: Fix issue where `getTransactionSize()` undersized inputs when using `placeholderP2PKHUnlocker()`. ## v0.13.2 diff --git a/website/docs/sdk/electrum-network-provider.md b/website/docs/sdk/electrum-network-provider.md index 51e37f936..25fa5ac78 100644 --- a/website/docs/sdk/electrum-network-provider.md +++ b/website/docs/sdk/electrum-network-provider.md @@ -54,16 +54,21 @@ const provider = new ElectrumNetworkProvider('chipnet', { hostname }); ### getUtxos() ```ts -async provider.getUtxos(address: string): Promise; +async provider.getUtxos(address: string): Promise; ``` Returns all UTXOs on specific address. Both confirmed and unconfirmed UTXOs are included. ```ts +interface SpendableUtxo extends Utxo { + lockingBytecode: string; +} + interface Utxo { txid: string; vout: number; satoshis: bigint; token?: TokenDetails; + lockingBytecode?: string; } interface TokenDetails { @@ -82,7 +87,7 @@ const userUtxos = await provider.getUtxos(userAddress) ``` ### getUtxosForLockingBytecode() ```ts -async provider.getUtxosForLockingBytecode(lockingBytecode: Uint8Array | string): Promise; +async provider.getUtxosForLockingBytecode(lockingBytecode: Uint8Array | string): Promise; ``` Returns all UTXOs for a specific locking bytecode. Both confirmed and unconfirmed UTXOs are included. diff --git a/website/docs/sdk/instantiation.md b/website/docs/sdk/instantiation.md index 858aa066c..d6ef2d8c5 100644 --- a/website/docs/sdk/instantiation.md +++ b/website/docs/sdk/instantiation.md @@ -161,17 +161,22 @@ const contractBalance = await contract.getBalance() ### getUtxos() ```ts -async contract.getUtxos(): Promise +async contract.getUtxos(): Promise ``` Returns all UTXOs that can be spent by the contract. Both confirmed and unconfirmed UTXOs are included. ```ts +interface SpendableUtxo extends Utxo { + lockingBytecode: string; +} + interface Utxo { txid: string; vout: number; satoshis: bigint; token?: TokenDetails; + lockingBytecode?: string; } ``` diff --git a/website/docs/sdk/network-provider.md b/website/docs/sdk/network-provider.md index 24062f68b..cd21ad9bd 100644 --- a/website/docs/sdk/network-provider.md +++ b/website/docs/sdk/network-provider.md @@ -24,16 +24,21 @@ const connectedNetwork = provider.network; ### getUtxos() ```ts -async provider.getUtxos(address: string): Promise; +async provider.getUtxos(address: string): Promise; ``` Returns all UTXOs on specific address. Both confirmed and unconfirmed UTXOs are included. ```ts +interface SpendableUtxo extends Utxo { + lockingBytecode: string; +} + interface Utxo { txid: string; vout: number; satoshis: bigint; token?: TokenDetails; + lockingBytecode?: string; } interface TokenDetails { @@ -46,6 +51,8 @@ interface TokenDetails { } ``` +The `lockingBytecode` field contains the hex-encoded locking bytecode of the UTXO. It is set automatically on all UTXOs returned by network providers, and is required by the `TransactionBuilder` to spend the UTXO. + #### Example ```ts const userUtxos = await provider.getUtxos(userAddress) @@ -53,7 +60,7 @@ const userUtxos = await provider.getUtxos(userAddress) ### getUtxosForLockingBytecode() ```ts -async provider.getUtxosForLockingBytecode(lockingBytecode: Uint8Array | string): Promise; +async provider.getUtxosForLockingBytecode(lockingBytecode: Uint8Array | string): Promise; ``` Returns all UTXOs for a specific locking bytecode. Both confirmed and unconfirmed UTXOs are included. @@ -98,7 +105,7 @@ const txId = await provider.sendRawTransaction(txHex) ## Custom NetworkProviders -A big strength of the NetworkProvider setup is that it allows you to implement custom providers. So if you want to use a new or different BCH indexer for network information, it is simple to add support for it by creating your own `NetworkProvider` adapter by implementing the [NetworkProvider interface](https://github.com/CashScript/cashscript/blob/master/packages/cashscript/src/network/NetworkProvider.ts). +A big strength of the NetworkProvider setup is that it allows you to implement custom providers. So if you want to use a new or different BCH indexer for network information, it is simple to add support for it by creating your own `NetworkProvider` adapter by implementing the [NetworkProvider interface](https://github.com/CashScript/cashscript/blob/master/packages/cashscript/src/network/NetworkProvider.ts). Note that UTXOs returned by a `NetworkProvider` must include their `lockingBytecode`, which is required by the `TransactionBuilder` to spend them. You can create a PR to add your custom `NetworkProvider` to the CashScript codebase to share this functionality with others. It is required to have basic automated tests for any new `NetworkProvider`. diff --git a/website/docs/sdk/other-network-providers.md b/website/docs/sdk/other-network-providers.md index 019a50d8a..e66d1e3f6 100644 --- a/website/docs/sdk/other-network-providers.md +++ b/website/docs/sdk/other-network-providers.md @@ -15,11 +15,6 @@ The `MockNetworkProvider` has extra methods to enable this local emulation such You can read more about the `MockNetworkProvider` and automated tests on the [testing setup](/docs/sdk/testing-setup) page. ```ts -interface MockNetworkProviderOptions { - updateUtxoSet?: boolean; - vmTarget?: VmTarget; -} - interface MockNetworkProvider extends NetworkProvider { options: MockNetworkProviderOptions; vmTarget: VmTarget; @@ -29,17 +24,27 @@ interface MockNetworkProvider extends NetworkProvider { // Hardcode the block height setBlockHeight(newBlockHeight: number): void; - // Add a UTXO to the UTXO set of the mock network - addUtxo(addressOrLockingBytecode: string, utxo: Utxo): Utxo; + // Add a UTXO to the UTXO set of the mock network, returns the UTXO including its locking bytecode + addUtxo(addressOrLockingBytecode: string, utxo: Utxo): SpendableUtxo; // Reset the UTXO set and transaction list of the mock network reset(): void; } ``` -The `updateUtxoSet` option is used to determine whether the UTXO set should be updated after a transaction is sent. If `updateUtxoSet` is `true` (default), the UTXO set will be updated to reflect the new state of the mock network. If `updateUtxoSet` is `false`, the UTXO set will not be updated. +### Options + +```ts +interface MockNetworkProviderOptions { + updateUtxoSet?: boolean; + validateTransactions?: boolean; + vmTarget?: VmTarget; +} +``` -The `vmTarget` option defaults to the current VM of `BCH_2026_05`, but this can be changed to test your contract against different BCH virtual machine targets. +- `updateUtxoSet` (default `true`) — update the in-memory UTXO set after a transaction is sent, consuming the spent UTXOs and adding the transaction's outputs. +- `validateTransactions` (default `true`) — evaluate sent transactions against the BCH VM using the actual locking bytecode of the spent UTXOs, rejecting transactions that a real node would reject. Requires `updateUtxoSet`. +- `vmTarget` (default `BCH_2026_05`) — the BCH virtual machine version used for local debugging and transaction validation. #### Example ```ts diff --git a/website/docs/sdk/transaction-builder.md b/website/docs/sdk/transaction-builder.md index 6871f3179..ea322a7f6 100644 --- a/website/docs/sdk/transaction-builder.md +++ b/website/docs/sdk/transaction-builder.md @@ -54,7 +54,7 @@ The `allowImplicitFungibleTokenBurn` option is used to specify whether implicit ### addInput() ```ts -transactionBuilder.addInput(utxo: Utxo, unlocker: Unlocker, options?: InputOptions): this +transactionBuilder.addInput(utxo: SpendableUtxo, unlocker: Unlocker, options?: InputOptions): this ``` Adds a single input UTXO to the transaction that can be unlocked using the provided unlocker. The unlocker can be derived from a `SignatureTemplate` or a `Contract` instance's spending functions. The `InputOptions` object can be used to specify the sequence number of the input. The default sequence number is `0xfffffffe` (non-final sequence number). @@ -76,12 +76,12 @@ transactionBuilder.addInput(aliceUtxos[0], aliceTemplate.unlockP2PKH()); ### addInputs() ```ts -transactionBuilder.addInputs(utxos: Utxo[], unlocker: Unlocker, options?: InputOptions): this +transactionBuilder.addInputs(utxos: SpendableUtxo[], unlocker: Unlocker, options?: InputOptions): this transactionBuilder.addInputs(utxos: UnlockableUtxo[]): this ``` ```ts -interface UnlockableUtxo extends Utxo { +interface UnlockableUtxo extends SpendableUtxo { unlocker: Unlocker; options?: InputOptions; }