Skip to content

Add support for placeholder-based artifacts #321

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion packages/cashscript/src/Argument.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { hexToBin } from '@bitauth/libauth';
import { binToHex, hexToBin } from '@bitauth/libauth';
import {
AbiFunction,
Artifact,
Expand Down Expand Up @@ -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 `<parameterName>` which is present in `bytecode` property.
// Signature templates are not supported in the bytecode replacement.
export const replaceArtifactPlaceholders = <T extends Artifact>(artifact: T, parameters: Record<string, FunctionArgument>): 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<string, string>);

// 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;
}
6 changes: 3 additions & 3 deletions packages/cashscript/src/Contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
}

Expand All @@ -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);
});
}

Expand Down
8 changes: 6 additions & 2 deletions packages/cashscript/src/LibauthTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
HashType,
isUnlockableUtxo,
isStandardUnlockableUtxo,
ContractOptions,
} from './interfaces.js';
import SignatureTemplate from './SignatureTemplate.js';
import { Transaction } from './Transaction.js';
Expand Down Expand Up @@ -68,6 +69,7 @@ export const buildTemplate = async ({
transaction.abiFunction,
transaction.encodedFunctionArgs,
contract.encodedConstructorArgs,
contract.options,
),
scenarios: generateTemplateScenarios(
contract,
Expand Down Expand Up @@ -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),
};
};
Expand All @@ -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', `<function_index> // int = <${functionIndex}>`, '']
: [];

Expand Down
6 changes: 4 additions & 2 deletions packages/cashscript/src/advanced/LibauthTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { EncodedConstructorArgument, EncodedFunctionArgument, encodeFunctionArgu
import { Contract } from '../Contract.js';
import { DebugResults, debugTemplate } from '../debugging.js';
import {
ContractOptions,
isP2PKHUnlocker,
isStandardUnlockableUtxo,
StandardUnlockableUtxo,
Expand Down Expand Up @@ -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),
};
};
Expand Down Expand Up @@ -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', `<function_index> // int = <${functionIndex}>`, '']
: [];

Expand Down
1 change: 1 addition & 0 deletions packages/cashscript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/cashscript/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export interface TransactionDetails extends Transaction {
export interface ContractOptions {
provider?: NetworkProvider,
addressType?: AddressType,
ignoreFunctionSelector?: boolean,
}

export type AddressType = 'p2sh20' | 'p2sh32';
58 changes: 58 additions & 0 deletions packages/cashscript/test/e2e/Multisig_2of3.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Multisig_2of3Artifact>;

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();
});
});
78 changes: 78 additions & 0 deletions packages/cashscript/test/e2e/PlaceholderArtifacts.test.ts
Original file line number Diff line number Diff line change
@@ -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: "<publickey> OP_SIZE 21 OP_EQUALVERIFY OP_DROP <bounded_bytes2> OP_SIZE OP_2 OP_EQUALVERIFY OP_DROP <rawbytes> OP_SIZE OP_4 OP_EQUALVERIFY OP_DROP <datasignature> OP_DROP <signature> OP_DROP <0x_text> 61626364 OP_EQUALVERIFY <text> 61626364 OP_EQUALVERIFY <integer> OP_16 OP_EQUALVERIFY <boolean> 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();
});
});
31 changes: 31 additions & 0 deletions packages/cashscript/test/fixture/Multisig_2of3.artifact.ts
Original file line number Diff line number Diff line change
@@ -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 <pubkeyC> <pubkeyB> <pubkeyA> OP_3 OP_CHECKMULTISIG",
source: "",
compiler: {
name: "cashc",
version: "0.11.0"
},
updatedAt: "2025-06-12T06:27:28.123Z"
} as const;