Skip to content

Commit

Permalink
Multi Contract and debugging support for Advanced Transaction Builder (
Browse files Browse the repository at this point in the history
…#257)

Co-authored-by: Rosco Kalis <roscokalis@gmail.com>
Co-authored-by: Mathieu Geukens <mr-zwets@protonmail.com>
  • Loading branch information
3 people authored Feb 27, 2025
1 parent 1691485 commit f19516f
Show file tree
Hide file tree
Showing 46 changed files with 4,018 additions and 1,414 deletions.
1 change: 1 addition & 0 deletions examples/common-js.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export const alicePub = secp256k1.derivePublicKeyCompressed(aliceNode.privateKey
export const alicePriv = aliceNode.privateKey;
export const alicePkh = hash160(alicePub);
export const aliceAddress = encodeCashAddress({ prefix: 'bchtest', type: 'p2pkh', payload: alicePkh, throwErrors: true }).address;
export const aliceTokenAddress = encodeCashAddress({ prefix: 'bchtest', type: 'p2pkhWithTokens', payload: alicePkh, throwErrors: true }).address;
2 changes: 2 additions & 0 deletions packages/cashscript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"@bitauth/libauth": "^3.1.0-next.2",
"@cashscript/utils": "^0.11.0-next.0",
"@mr-zwets/bchn-api-wrapper": "^1.0.1",
"change-case": "^5.4.4",
"delay": "^6.0.0",
"electrum-cash": "^2.0.10",
"fast-deep-equal": "^3.1.3",
Expand All @@ -55,6 +56,7 @@
"devDependencies": {
"@jest/globals": "^29.7.0",
"@psf/bch-js": "^6.8.0",
"@types/change-case": "^2.3.5",
"@types/pako": "^2.0.3",
"@types/semver": "^7.5.8",
"eslint": "^8.54.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/cashscript/src/Contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export class Contract<

const generateLockingBytecode = (): Uint8Array => addressToLockScript(this.address);

return { generateUnlockingBytecode, generateLockingBytecode };
return { generateUnlockingBytecode, generateLockingBytecode, contract: this, params: args, abiFunction };
};
}
}
Expand Down
12 changes: 12 additions & 0 deletions packages/cashscript/src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,24 @@ export class TypeError extends Error {
}
}

export class UndefinedInputError extends Error {
constructor() {
super('Input is undefined');
}
}

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})`);
}
}

export class OutputTokenAmountTooSmallError extends Error {
constructor(amount: bigint) {
super(`Tried to add an output with ${amount} tokens, which is invalid`);
}
}

export class TokensToNonTokenAddressError extends Error {
constructor(address: string) {
super(`Tried to send tokens to an address without token support, ${address}.`);
Expand Down
77 changes: 48 additions & 29 deletions packages/cashscript/src/LibauthTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export const buildTemplate = async ({
const template = {
$schema: 'https://ide.bitauth.com/authentication-template-v0.schema.json',
description: 'Imported from cashscript',
name: contract.artifact.contractName,
name: 'CashScript Generated Debugging Template',
supported: ['BCH_2023_05'],
version: 0,
entities: generateTemplateEntities(contract.artifact, transaction.abiFunction, transaction.encodedFunctionArgs),
Expand All @@ -77,7 +77,6 @@ export const buildTemplate = async ({
),
} as WalletTemplate;


transaction.inputs
.forEach((input, index) => {
if (!isUtxoP2PKH(input)) return;
Expand All @@ -90,9 +89,9 @@ export const buildTemplate = async ({
const hashtypeName = getHashTypeName(input.template.getHashType(false));
const signatureString = `${placeholderKeyName}.${signatureAlgorithmName}.${hashtypeName}`;

template.entities.parameters.scripts!.push(lockScriptName, unlockScriptName);
template.entities.parameters.variables = {
...template.entities.parameters.variables,
template.entities[snakeCase(contract.name + 'Parameters')].scripts!.push(lockScriptName, unlockScriptName);
template.entities[snakeCase(contract.name + 'Parameters')].variables = {
...template.entities[snakeCase(contract.name + 'Parameters')].variables,
[placeholderKeyName]: {
description: placeholderKeyName,
name: placeholderKeyName,
Expand Down Expand Up @@ -154,12 +153,12 @@ const generateTemplateEntities = (
);

const entities = {
parameters: {
[snakeCase(artifact.contractName + 'Parameters')]: {
description: 'Contract creation and function parameters',
name: 'parameters',
name: snakeCase(artifact.contractName + 'Parameters'),
scripts: [
'lock',
'unlock_lock',
snakeCase(artifact.contractName + '_lock'),
snakeCase(artifact.contractName + '_unlock'),
],
variables: {
...functionParameters,
Expand All @@ -170,7 +169,7 @@ const generateTemplateEntities = (

// function_index is a special variable that indicates the function to execute
if (artifact.abi.length > 1) {
entities.parameters.variables.function_index = {
entities[snakeCase(artifact.contractName + 'Parameters')].variables.function_index = {
description: 'Script function index to execute',
name: 'function_index',
type: 'WalletData',
Expand All @@ -189,8 +188,8 @@ const generateTemplateScripts = (
): WalletTemplate['scripts'] => {
// definition of locking scripts and unlocking scripts with their respective bytecode
return {
unlock_lock: generateTemplateUnlockScript(artifact, abiFunction, encodedFunctionArgs),
lock: generateTemplateLockScript(artifact, addressType, encodedConstructorArgs),
[snakeCase(artifact.contractName + '_unlock')]: generateTemplateUnlockScript(artifact, abiFunction, encodedFunctionArgs),
[snakeCase(artifact.contractName + '_lock')]: generateTemplateLockScript(artifact, addressType, encodedConstructorArgs),
};
};

Expand All @@ -201,7 +200,7 @@ const generateTemplateLockScript = (
): WalletTemplateScriptLocking => {
return {
lockingType: addressType,
name: 'lock',
name: snakeCase(artifact.contractName + '_lock'),
script: [
`// "${artifact.contractName}" contract constructor parameters`,
formatParametersForDebugging(artifact.constructorInputs, constructorArguments),
Expand All @@ -225,15 +224,15 @@ const generateTemplateUnlockScript = (

return {
// this unlocking script must pass our only scenario
passes: ['evaluate_function'],
name: 'unlock',
passes: [snakeCase(artifact.contractName + 'Evaluate')],
name: snakeCase(artifact.contractName + '_unlock'),
script: [
`// "${abiFunction.name}" function parameters`,
formatParametersForDebugging(abiFunction.inputs, encodedFunctionArgs),
'',
...functionIndexString,
].join('\n'),
unlocks: 'lock',
unlocks: snakeCase(artifact.contractName + '_lock'),
};
};

Expand All @@ -251,8 +250,8 @@ const generateTemplateScenarios = (

const scenarios = {
// single scenario to spend out transaction under test given the CashScript parameters provided
evaluate_function: {
name: 'Evaluate',
[snakeCase(artifact.contractName + 'Evaluate')]: {
name: snakeCase(artifact.contractName + 'Evaluate'),
description: 'An example evaluation where this script execution passes.',
data: {
// encode values for the variables defined above in `entities` property
Expand All @@ -273,7 +272,7 @@ const generateTemplateScenarios = (

if (artifact.abi.length > 1) {
const functionIndex = artifact.abi.findIndex((func) => func.name === transaction.abiFunction.name);
scenarios!.evaluate_function!.data!.bytecode!.function_index = functionIndex.toString();
scenarios![snakeCase(artifact.contractName + 'Evaluate')].data!.bytecode!.function_index = functionIndex.toString();
}

return scenarios;
Expand Down Expand Up @@ -314,7 +313,7 @@ const generateTemplateScenarioTransaction = (
return { inputs, locktime, outputs, version };
};

const generateTemplateScenarioTransactionOutputLockingBytecode = (
export const generateTemplateScenarioTransactionOutputLockingBytecode = (
csOutput: Output,
contract: Contract,
): string | {} => {
Expand All @@ -323,6 +322,20 @@ const generateTemplateScenarioTransactionOutputLockingBytecode = (
return binToHex(addressToLockScript(csOutput.to));
};

/**
* Generates source outputs for a BitAuth template scenario
*
* @param csTransaction - The CashScript transaction to generate source outputs for
* @returns An array of BitAuth template scenario outputs with locking scripts and values
*
* For each input in the transaction:
* - Generates appropriate locking bytecode (P2PKH or contract)
* - Includes the input value in satoshis
* - Includes any token details if present
*
* The slotIndex tracks which input is the contract input vs P2PKH inputs
* to properly generate the locking scripts.
*/
const generateTemplateScenarioSourceOutputs = (
csTransaction: Transaction,
): Array<WalletTemplateScenarioOutput<true>> => {
Expand All @@ -338,7 +351,7 @@ const generateTemplateScenarioSourceOutputs = (
};

// Used for generating the locking / unlocking bytecode for source outputs and inputs
const generateTemplateScenarioBytecode = (
export const generateTemplateScenarioBytecode = (
input: Utxo, p2pkhScriptName: string, placeholderKeyName: string, insertSlot?: boolean,
): WalletTemplateScenarioBytecode | ['slot'] => {
if (isUtxoP2PKH(input)) {
Expand All @@ -357,7 +370,7 @@ const generateTemplateScenarioBytecode = (
return insertSlot ? ['slot'] : {};
};

const generateTemplateScenarioParametersValues = (
export const generateTemplateScenarioParametersValues = (
types: readonly AbiInput[],
encodedArgs: EncodedFunctionArgument[],
): Record<string, string> => {
Expand All @@ -368,14 +381,18 @@ const generateTemplateScenarioParametersValues = (
.filter(([, arg]) => !(arg instanceof SignatureTemplate))
.map(([input, arg]) => {
const encodedArgumentHex = binToHex(arg as Uint8Array);
const prefixedEncodedArgument = encodedArgumentHex.length > 0 ? `0x${encodedArgumentHex}` : '';
const prefixedEncodedArgument = addHexPrefixExceptEmpty(encodedArgumentHex);
return [snakeCase(input.name), prefixedEncodedArgument] as const;
});

return Object.fromEntries(entries);
};

const generateTemplateScenarioKeys = (
export const addHexPrefixExceptEmpty = (value: string): string => {
return value.length > 0 ? `0x${value}` : '';
};

export const generateTemplateScenarioKeys = (
types: readonly AbiInput[],
encodedArgs: EncodedFunctionArgument[],
): Record<string, string> => {
Expand All @@ -388,7 +405,7 @@ const generateTemplateScenarioKeys = (
return Object.fromEntries(entries);
};

const formatParametersForDebugging = (types: readonly AbiInput[], args: EncodedFunctionArgument[]): string => {
export const formatParametersForDebugging = (types: readonly AbiInput[], args: EncodedFunctionArgument[]): string => {
if (types.length === 0) return '// none';

// We reverse the arguments because the order of the arguments in the bytecode is reversed
Expand All @@ -409,7 +426,7 @@ const formatParametersForDebugging = (types: readonly AbiInput[], args: EncodedF
}).join('\n');
};

const getSignatureAlgorithmName = (signatureAlgorithm: SignatureAlgorithm): string => {
export const getSignatureAlgorithmName = (signatureAlgorithm: SignatureAlgorithm): string => {
const signatureAlgorithmNames = {
[SignatureAlgorithm.SCHNORR]: 'schnorr_signature',
[SignatureAlgorithm.ECDSA]: 'ecdsa_signature',
Expand All @@ -418,7 +435,7 @@ const getSignatureAlgorithmName = (signatureAlgorithm: SignatureAlgorithm): stri
return signatureAlgorithmNames[signatureAlgorithm];
};

const getHashTypeName = (hashType: HashType): string => {
export const getHashTypeName = (hashType: HashType): string => {
const hashtypeNames = {
[HashType.SIGHASH_ALL]: 'all_outputs',
[HashType.SIGHASH_ALL | HashType.SIGHASH_ANYONECANPAY]: 'all_outputs_single_input',
Expand All @@ -437,7 +454,7 @@ const getHashTypeName = (hashType: HashType): string => {
return hashtypeNames[hashType];
};

const formatBytecodeForDebugging = (artifact: Artifact): string => {
export const formatBytecodeForDebugging = (artifact: Artifact): string => {
if (!artifact.debug) {
return artifact.bytecode
.split(' ')
Expand All @@ -452,7 +469,9 @@ const formatBytecodeForDebugging = (artifact: Artifact): string => {
);
};

const serialiseTokenDetails = (token?: TokenDetails | LibauthTokenDetails): LibauthTemplateTokenDetails | undefined => {
export const serialiseTokenDetails = (
token?: TokenDetails | LibauthTokenDetails,
): LibauthTemplateTokenDetails | undefined => {
if (!token) return undefined;

return {
Expand Down
1 change: 1 addition & 0 deletions packages/cashscript/src/SignatureTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export default class SignatureTemplate {
const unlockingBytecode = scriptToBytecode([signature, publicKey]);
return unlockingBytecode;
},
template: this,
};
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/cashscript/src/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { P2PKH_INPUT_SIZE } from './constants.js';
import { TransactionBuilder } from './TransactionBuilder.js';
import { Contract } from './Contract.js';
import { buildTemplate, getBitauthUri } from './LibauthTemplate.js';
import { debugTemplate, DebugResult } from './debugging.js';
import { debugTemplate, DebugResults } from './debugging.js';
import { EncodedFunctionArgument } from './Argument.js';
import { FailedTransactionError } from './Errors.js';

Expand Down Expand Up @@ -185,13 +185,13 @@ export class Transaction {
}

// method to debug the transaction with libauth VM, throws upon evaluation error
async debug(): Promise<DebugResult> {
async debug(): Promise<DebugResults> {
if (!this.contract.artifact.debug) {
console.warn('No debug information found in artifact. Recompile with cashc version 0.10.0 or newer to get better debugging information.');
}

const template = await this.getLibauthTemplate();
return debugTemplate(template, this.contract.artifact);
return debugTemplate(template, [this.contract.artifact]);
}

async bitauthUri(): Promise<string> {
Expand Down
Loading

0 comments on commit f19516f

Please sign in to comment.