diff --git a/packages/cashscript/src/Argument.ts b/packages/cashscript/src/Argument.ts index 915fe84d..b4cb13a6 100644 --- a/packages/cashscript/src/Argument.ts +++ b/packages/cashscript/src/Argument.ts @@ -1,4 +1,4 @@ -import { hexToBin } from '@bitauth/libauth'; +import { binToHex, hexToBin } from '@bitauth/libauth'; import { AbiFunction, Artifact, @@ -104,3 +104,48 @@ export const encodeFunctionArguments = ( return encodedArgs; }; + +// This function replaces placeholders in the artifact bytecode with the encoded values of the provided parameters. +// A placeholder is a string in the format `` which is present in `bytecode` property. +// Signature templates are not supported in the bytecode replacement. +export const replaceArtifactPlaceholders = (artifact: T, parameters: Record): T => { + // first, collect known ABI types + const argumentTypes = [...artifact.constructorInputs, ...Object.values(artifact.abi).map(f => f.inputs).flat()].reduce((acc, input) => { + acc[input.name] = input.type; + return acc; + }, {} as Record); + + // then, replace placeholders in bytecode with known ABI types with a fallback to derived type or to bytes otherwise + Object.entries(parameters).forEach(([key, value]) => { + const primitiveType = argumentTypes[key] ?? getPrimitiveType(value) ?? PrimitiveType.ANY; + if (primitiveType === PrimitiveType.SIG && value instanceof SignatureTemplate) { + throw new Error(`Cannot use signature templates in bytecode replacement for artifact ${artifact.contractName}`); + } + + artifact.bytecode = artifact.bytecode.replaceAll(`<${key}>`, binToHex(encodeFunctionArgument(value, argumentTypes[key] ?? getPrimitiveType(value) ?? PrimitiveType.ANY) as Uint8Array)); + }); + + if (artifact.bytecode.includes('<')) { + throw new Error(`Not all placeholders in artifact ${artifact.contractName} were replaced. Remaining placeholders: ${artifact.bytecode.match(/<[^>]+>/g)?.join(', ')}`); + } + + return artifact; +}; + +const getPrimitiveType = (value: bigint | string | boolean | Uint8Array | SignatureTemplate): PrimitiveType | undefined => { + if (typeof value === 'boolean') return PrimitiveType.BOOL; + if (typeof value === 'bigint') return PrimitiveType.INT; + if (typeof value === 'string') { + if (value.startsWith('0x')) { + return PrimitiveType.ANY; + } + return PrimitiveType.STRING; + } + if (value instanceof Uint8Array) { + if (value.byteLength === 65) return PrimitiveType.SIG; + if (value.byteLength === 64) return PrimitiveType.DATASIG; + return PrimitiveType.ANY; + } + if (value instanceof SignatureTemplate) return PrimitiveType.SIG; + return undefined; +} \ No newline at end of file diff --git a/packages/cashscript/src/Contract.ts b/packages/cashscript/src/Contract.ts index e2baf3d2..24a160ed 100644 --- a/packages/cashscript/src/Contract.ts +++ b/packages/cashscript/src/Contract.ts @@ -57,7 +57,7 @@ export class Contract< constructor( public artifact: TArtifact, constructorArgs: TResolved['constructorInputs'], - private options?: ContractOptions, + public options?: ContractOptions, ) { this.provider = this.options?.provider ?? new ElectrumNetworkProvider(); this.addressType = this.options?.addressType ?? 'p2sh32'; @@ -90,7 +90,7 @@ export class Contract< } else { artifact.abi.forEach((f, i) => { // @ts-ignore TODO: see if we can use generics to make TypeScript happy - this.functions[f.name] = this.createFunction(f, i); + this.functions[f.name] = this.createFunction(f, this.options.ignoreFunctionSelector ? undefined : i); }); } @@ -104,7 +104,7 @@ export class Contract< } else { artifact.abi.forEach((f, i) => { // @ts-ignore TODO: see if we can use generics to make TypeScript happy - this.unlock[f.name] = this.createUnlocker(f, i); + this.unlock[f.name] = this.createUnlocker(f, this.options.ignoreFunctionSelector ? undefined : i); }); } diff --git a/packages/cashscript/src/LibauthTemplate.ts b/packages/cashscript/src/LibauthTemplate.ts index dc4ae1e0..71d042e5 100644 --- a/packages/cashscript/src/LibauthTemplate.ts +++ b/packages/cashscript/src/LibauthTemplate.ts @@ -35,6 +35,7 @@ import { HashType, isUnlockableUtxo, isStandardUnlockableUtxo, + ContractOptions, } from './interfaces.js'; import SignatureTemplate from './SignatureTemplate.js'; import { Transaction } from './Transaction.js'; @@ -68,6 +69,7 @@ export const buildTemplate = async ({ transaction.abiFunction, transaction.encodedFunctionArgs, contract.encodedConstructorArgs, + contract.options, ), scenarios: generateTemplateScenarios( contract, @@ -188,10 +190,11 @@ const generateTemplateScripts = ( abiFunction: AbiFunction, encodedFunctionArgs: EncodedFunctionArgument[], encodedConstructorArgs: EncodedConstructorArgument[], + contractOptions?: ContractOptions, ): WalletTemplate['scripts'] => { // definition of locking scripts and unlocking scripts with their respective bytecode return { - [artifact.contractName + '_unlock']: generateTemplateUnlockScript(artifact, abiFunction, encodedFunctionArgs), + [artifact.contractName + '_unlock']: generateTemplateUnlockScript(artifact, abiFunction, encodedFunctionArgs, contractOptions), [artifact.contractName + '_lock']: generateTemplateLockScript(artifact, addressType, encodedConstructorArgs), }; }; @@ -218,10 +221,11 @@ const generateTemplateUnlockScript = ( artifact: Artifact, abiFunction: AbiFunction, encodedFunctionArgs: EncodedFunctionArgument[], + contractOptions?: ContractOptions, ): WalletTemplateScriptUnlocking => { const functionIndex = artifact.abi.findIndex((func) => func.name === abiFunction.name); - const functionIndexString = artifact.abi.length > 1 + const functionIndexString = artifact.abi.length > 1 && !contractOptions?.ignoreFunctionSelector ? ['// function index in contract', ` // int = <${functionIndex}>`, ''] : []; diff --git a/packages/cashscript/src/advanced/LibauthTemplate.ts b/packages/cashscript/src/advanced/LibauthTemplate.ts index c1b7f587..200d385d 100644 --- a/packages/cashscript/src/advanced/LibauthTemplate.ts +++ b/packages/cashscript/src/advanced/LibauthTemplate.ts @@ -22,6 +22,7 @@ import { EncodedConstructorArgument, EncodedFunctionArgument, encodeFunctionArgu import { Contract } from '../Contract.js'; import { DebugResults, debugTemplate } from '../debugging.js'; import { + ContractOptions, isP2PKHUnlocker, isStandardUnlockableUtxo, StandardUnlockableUtxo, @@ -198,7 +199,7 @@ export const generateTemplateScriptsP2SH = ( const lockingScriptName = getLockScriptName(contract); return { - [unlockingScriptName]: generateTemplateUnlockScript(contract, abiFunction, encodedFunctionArgs, inputIndex), + [unlockingScriptName]: generateTemplateUnlockScript(contract, abiFunction, encodedFunctionArgs, inputIndex, contract.options), [lockingScriptName]: generateTemplateLockScript(contract, encodedConstructorArgs), }; }; @@ -239,11 +240,12 @@ const generateTemplateUnlockScript = ( abiFunction: AbiFunction, encodedFunctionArgs: EncodedFunctionArgument[], inputIndex: number, + contractOptions?: ContractOptions, ): WalletTemplateScriptUnlocking => { const scenarioIdentifier = `${contract.artifact.contractName}_${abiFunction.name}_input${inputIndex}_evaluate`; const functionIndex = contract.artifact.abi.findIndex((func) => func.name === abiFunction.name); - const functionIndexString = contract.artifact.abi.length > 1 + const functionIndexString = contract.artifact.abi.length > 1 && !contractOptions?.ignoreFunctionSelector ? ['// function index in contract', ` // int = <${functionIndex}>`, ''] : []; diff --git a/packages/cashscript/src/index.ts b/packages/cashscript/src/index.ts index 53c364bb..f2994b8e 100644 --- a/packages/cashscript/src/index.ts +++ b/packages/cashscript/src/index.ts @@ -8,6 +8,7 @@ export { type EncodedConstructorArgument, type EncodedFunctionArgument, encodeFunctionArgument, + replaceArtifactPlaceholders, } from './Argument.js'; export type { Artifact, AbiFunction, AbiInput } from '@cashscript/utils'; export * as utils from '@cashscript/utils'; diff --git a/packages/cashscript/src/interfaces.ts b/packages/cashscript/src/interfaces.ts index 22d9a66a..1f8b37f0 100644 --- a/packages/cashscript/src/interfaces.ts +++ b/packages/cashscript/src/interfaces.ts @@ -153,6 +153,7 @@ export interface TransactionDetails extends Transaction { export interface ContractOptions { provider?: NetworkProvider, addressType?: AddressType, + ignoreFunctionSelector?: boolean, } export type AddressType = 'p2sh20' | 'p2sh32'; diff --git a/packages/cashscript/test/e2e/Multisig_2of3.test.ts b/packages/cashscript/test/e2e/Multisig_2of3.test.ts new file mode 100644 index 00000000..094ade15 --- /dev/null +++ b/packages/cashscript/test/e2e/Multisig_2of3.test.ts @@ -0,0 +1,58 @@ +import { + Contract, + ElectrumNetworkProvider, + MockNetworkProvider, + replaceArtifactPlaceholders, + SignatureTemplate, + TransactionBuilder +} from '../../src/index.js'; +import { Network } from '../../src/interfaces.js'; +import Multisig_2of3Artifact from '../fixture/Multisig_2of3.artifact.js'; +import { alicePriv, alicePub, bobPriv, bobPub, carolPub } from '../fixture/vars.js'; +import { gatherUtxos } from '../test-util.js'; + +// A 2 of 3 multisig contract compatible with ElectronCash multisig wallets +describe('Multisig_2of3 placeholder artifact tests', () => { + const provider = process.env.TESTS_USE_MOCKNET + ? new MockNetworkProvider() + : new ElectrumNetworkProvider(Network.CHIPNET); + let multisig_2of3: Contract; + + beforeEach(() => { + const artifact = replaceArtifactPlaceholders(Multisig_2of3Artifact, { + pubkeyA: alicePub, + pubkeyB: bobPub, + pubkeyC: carolPub, + }); + + multisig_2of3 = new Contract(artifact, [], { provider, ignoreFunctionSelector: true, addressType: 'p2sh20' }); + }); + + it('spend', async () => { + const to = multisig_2of3.address; + const amount = 1000n; + const { utxos, changeAmount } = gatherUtxos(await multisig_2of3.getUtxos(), { amount }); + + // when + const txPromise = new TransactionBuilder({ provider }) + .addInputs(utxos, multisig_2of3.unlock.spend(new SignatureTemplate(alicePriv), new SignatureTemplate(bobPriv), BigInt(0b110))) + .addOutput({ to, amount: changeAmount }) + .send(); + + await expect(txPromise).resolves.not.toThrow(); + }); + + it('wrong checkbits', async () => { + const to = multisig_2of3.address; + const amount = 1000n; + const { utxos, changeAmount } = gatherUtxos(await multisig_2of3.getUtxos(), { amount }); + + // when + const txPromise = new TransactionBuilder({ provider }) + .addInputs(utxos, multisig_2of3.unlock.spend(new SignatureTemplate(alicePriv), new SignatureTemplate(bobPriv), BigInt(0b011))) + .addOutput({ to, amount: changeAmount }) + .send(); + + await expect(txPromise).rejects.toThrow(); + }); +}); diff --git a/packages/cashscript/test/e2e/PlaceholderArtifacts.test.ts b/packages/cashscript/test/e2e/PlaceholderArtifacts.test.ts new file mode 100644 index 00000000..41c62de1 --- /dev/null +++ b/packages/cashscript/test/e2e/PlaceholderArtifacts.test.ts @@ -0,0 +1,78 @@ +import { placeholder } from '@cashscript/utils'; +import { + Contract, + MockNetworkProvider, + randomUtxo, + replaceArtifactPlaceholders, + SignatureTemplate, + TransactionBuilder +} from '../../src/index.js'; +import { alicePriv, alicePub } from '../fixture/vars.js'; +import { gatherUtxos } from '../test-util.js'; + +const artifact = { + contractName: "PlaceholderArtifact", + constructorInputs: [], + abi: [ + { + name: "spend", + inputs: [] + } + ], + bytecode: " OP_SIZE 21 OP_EQUALVERIFY OP_DROP OP_SIZE OP_2 OP_EQUALVERIFY OP_DROP OP_SIZE OP_4 OP_EQUALVERIFY OP_DROP OP_DROP OP_DROP <0x_text> 61626364 OP_EQUALVERIFY 61626364 OP_EQUALVERIFY OP_16 OP_EQUALVERIFY OP_1 OP_EQUALVERIFY OP_1", + source: "", + compiler: { + name: "cashc", + version: "0.11.0" + }, + updatedAt: "2025-06-12T06:27:28.123Z" +} as const; + +describe('Placeholder artifact tests', () => { + const provider = new MockNetworkProvider(); + + it('unreplaced placeholders left', async () => { + expect(() => replaceArtifactPlaceholders(structuredClone(artifact), { + })).toThrow(/Not all placeholders in artifact PlaceholderArtifact/); + }); + + it('SignatureTemplate not allowed', async () => { + expect(() => replaceArtifactPlaceholders(structuredClone(artifact), { + signature: new SignatureTemplate(alicePriv), + })).toThrow(/Cannot use signature templates/); + }); + + it('spend', async () => { + const replacedArtifact = replaceArtifactPlaceholders(structuredClone(artifact), { + boolean: true, + integer: 16n, + text: 'abcd', + '0x_text': '0x61626364', + signature: placeholder(65), + datasignature: placeholder(64), + rawbytes: new Uint8Array([1, 2, 3, 4]), + bounded_bytes2: new Uint8Array([5, 6]), + publickey: alicePub, + }); + + expect(replacedArtifact.bytecode).toBe( + '0373cc07b54c22da627b572a387a20ea190c9382e5e6d48c1d5b89c5cea2c4c088 OP_SIZE 21 OP_EQUALVERIFY OP_DROP 0506 OP_SIZE OP_2 OP_EQUALVERIFY OP_DROP 01020304 OP_SIZE OP_4 OP_EQUALVERIFY OP_DROP 00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 OP_DROP 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 OP_DROP 61626364 61626364 OP_EQUALVERIFY 61626364 61626364 OP_EQUALVERIFY 10 OP_16 OP_EQUALVERIFY 01 OP_1 OP_EQUALVERIFY OP_1' + ); + + const contract = new Contract(replacedArtifact, [], { provider }); + + provider.addUtxo(contract.address, randomUtxo()); + + const to = contract.address; + const amount = 1000n; + const { utxos, changeAmount } = gatherUtxos(await contract.getUtxos(), { amount }); + + // when + const txPromise = new TransactionBuilder({ provider }) + .addInputs(utxos, contract.unlock.spend()) + .addOutput({ to, amount: changeAmount }) + .send(); + + await expect(txPromise).resolves.not.toThrow(); + }); +}); diff --git a/packages/cashscript/test/fixture/Multisig_2of3.artifact.ts b/packages/cashscript/test/fixture/Multisig_2of3.artifact.ts new file mode 100644 index 00000000..826e5980 --- /dev/null +++ b/packages/cashscript/test/fixture/Multisig_2of3.artifact.ts @@ -0,0 +1,31 @@ +// A 2 of 3 multisig contract compatible with ElectronCash multisig wallets +export default { + contractName: "Multisig_2of3", + constructorInputs: [], + abi: [ + { + name: "spend", + inputs: [ + { + name: "signatureA", + type: "sig" + }, + { + name: "signatureB", + type: "sig" + }, + { + name: "checkbits", + type: "int" + } + ] + } + ], + bytecode: "OP_2 OP_3 OP_CHECKMULTISIG", + source: "", + compiler: { + name: "cashc", + version: "0.11.0" + }, + updatedAt: "2025-06-12T06:27:28.123Z" +} as const;