Skip to content
Closed
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
56 changes: 52 additions & 4 deletions packages/cashscript/src/TransactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isContractUnlocker,
BchChangeOutputOptions,
TokenChangeOutputOptions,
isPlaceholderUnlocker,
} from './interfaces.js';
import { NetworkProvider } from './network/index.js';
import {
Expand All @@ -36,9 +37,15 @@ import {
import { FailedTransactionError } from './Errors.js';
import { DebugResults } from './debugging.js';
import { debugLibauthTemplate, getLibauthTemplate, getBitauthUri } from './libauth-template/LibauthTemplate.js';
import { getWcContractInfo, WcSourceOutput, WcTransactionOptions } from './walletconnect-utils.js';
import {
getWcContractInfo,
WalletConnectSourceOutput,
WalletConnectTransactionOptions,
WizardConnectInputPath,
WizardConnectTransactionObject,
} from './walletconnect-utils.js';
import semver from 'semver';
import { WcTransactionObject } from './walletconnect-utils.js';
import { WalletConnectTransactionObject } from './walletconnect-utils.js';

/**
* Options accepted by the `TransactionBuilder` constructor.
Expand Down Expand Up @@ -551,6 +558,13 @@ export class TransactionBuilder {
throw new Error('Could not retrieve transaction details for over 10 minutes');
}

/**
* @deprecated Use `generateWalletConnectTransactionObject` instead.
*/
generateWcTransactionObject(options?: WalletConnectTransactionOptions): WalletConnectTransactionObject {
return this.generateWalletConnectTransactionObject(options);
}

/**
* Build the transaction and format it as a BCH WalletConnect transaction object suitable for
* signing and broadcasting via a BCH WalletConnect-compatible Bitcoin Cash wallet.
Expand All @@ -561,12 +575,12 @@ export class TransactionBuilder {
* @returns A WalletConnect transaction object ready to be sent to a WalletConnect wallet.
* @throws If the transaction cannot be built (fee exceeds limit or fungible tokens burned).
*/
generateWcTransactionObject(options?: WcTransactionOptions): WcTransactionObject {
generateWalletConnectTransactionObject(options?: WalletConnectTransactionOptions): WalletConnectTransactionObject {
const encodedTransaction = this.build();
const transaction = decodeTransactionUnsafe(hexToBin(encodedTransaction));

const libauthSourceOutputs = generateLibauthSourceOutputs(this.inputs);
const sourceOutputs: WcSourceOutput[] = libauthSourceOutputs.map((sourceOutput, index) => {
const sourceOutputs: WalletConnectSourceOutput[] = libauthSourceOutputs.map((sourceOutput, index) => {
return {
...sourceOutput,
...transaction.inputs[index],
Expand All @@ -575,4 +589,38 @@ export class TransactionBuilder {
});
return { ...options, transaction, sourceOutputs };
}

/**
* Build the transaction and format it as a WizardConnect transaction request.
*
* WizardConnect uses the standard BCH WalletConnect transaction object plus HD path metadata for
* each placeholder P2PKH input that the wallet must sign.
*
* @param options - Optional WalletConnect options such as `broadcast` and `userPrompt`.
* @returns A WizardConnect transaction object ready to be sent to a WizardConnect wallet.
* @throws If the transaction cannot be built, or if a placeholder input is missing HD path metadata.
*/
generateWizardConnectTransactionObject(options?: WalletConnectTransactionOptions): WizardConnectTransactionObject {
const transaction = this.generateWalletConnectTransactionObject(options);
const inputPaths = this.generateWizardConnectInputPaths();

return { transaction, inputPaths };
}

private generateWizardConnectInputPaths(): WizardConnectInputPath[] {
const inputPaths: WizardConnectInputPath[] = [];

this.inputs.forEach((input, inputIndex) => {
if (!isPlaceholderUnlocker(input.unlocker)) return;

const hdPath = input.unlocker.hdPath;
if (!hdPath) {
throw new Error(`Placeholder P2PKH input ${inputIndex} is missing WizardConnect HD path metadata`);
}

inputPaths.push([inputIndex, hdPath.name, hdPath.addressIndex]);
});

return inputPaths;
}
}
15 changes: 14 additions & 1 deletion packages/cashscript/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,20 @@ export interface P2PKHUnlocker extends Unlocker {

export type StandardUnlocker = ContractUnlocker | P2PKHUnlocker;

export type PlaceholderP2PKHUnlocker = Unlocker & { placeholder: true };
export interface PlaceholderHdPath {
name: string;
addressIndex: number;
}

export interface PlaceholderP2PKHUnlockerOptions {
hdPath?: PlaceholderHdPath;
}

export interface PlaceholderP2PKHUnlocker extends Unlocker {
placeholder: true;
address: string;
hdPath?: PlaceholderHdPath;
}

export type ContractFunctionUnlocker = (...args: FunctionArgument[]) => ContractUnlocker;

Expand Down
63 changes: 49 additions & 14 deletions packages/cashscript/src/walletconnect-utils.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,78 @@
import { type LibauthOutput, isContractUnlocker, type PlaceholderP2PKHUnlocker, type UnlockableUtxo } from './interfaces.js';
import {
type LibauthOutput,
isContractUnlocker,
type PlaceholderP2PKHUnlocker,
type PlaceholderP2PKHUnlockerOptions,
type UnlockableUtxo,
} from './interfaces.js';
import { type AbiFunction, type Artifact } from '@cashscript/utils';
import { 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

export interface WcTransactionOptions {
export interface WalletConnectTransactionOptions {
broadcast?: boolean;
userPrompt?: string;
}

export interface WcTransactionObject {
/** @deprecated Use `WalletConnectTransactionOptions` instead. */
export type WcTransactionOptions = WalletConnectTransactionOptions;

export interface WalletConnectTransactionObject {
transaction: TransactionCommon; // spec also allows for a tx hex string but we use the libauth transaction object
sourceOutputs: WcSourceOutput[];
sourceOutputs: WalletConnectSourceOutput[];
broadcast?: boolean;
userPrompt?: string;
}

export type WcSourceOutput = Input & LibauthOutput & WcContractInfo;
/** @deprecated Use `WalletConnectTransactionObject` instead. */
export type WcTransactionObject = WalletConnectTransactionObject;

export type WizardConnectInputPath = [inputIndex: number, pathName: string, addressIndex: number];

export interface WizardConnectTransactionObject {
transaction: WalletConnectTransactionObject;
inputPaths: WizardConnectInputPath[];
}

export type WalletConnectSourceOutput = Input & LibauthOutput & WalletConnectContractInfo;

/** @deprecated Use `WalletConnectSourceOutput` instead. */
export type WcSourceOutput = WalletConnectSourceOutput;

export interface WcContractInfo {
export interface WalletConnectContractInfo {
contract?: {
abiFunction: AbiFunction;
redeemScript: Uint8Array;
artifact: Partial<Artifact>;
}
}

export function getWcContractInfo(input: UnlockableUtxo): WcContractInfo | {} {
/** @deprecated Use `WalletConnectContractInfo` instead. */
export type WcContractInfo = WalletConnectContractInfo;

export function getWcContractInfo(input: UnlockableUtxo): WalletConnectContractInfo | {} {
// If the input does not have a contract unlocker, return an empty object
if (!(isContractUnlocker(input.unlocker))) return {};

const contract = input.unlocker.contract;
const abiFunctionName = input.unlocker.abiFunction?.name;
const abiFunction = contract.artifact.abi.find(abi => abi.name === abiFunctionName);

if (!abiFunction) {
throw new Error(`ABI function ${abiFunctionName} not found in contract artifact`);
}
const wcContractObj: WcContractInfo = {

const walletConnectContractObject: WalletConnectContractInfo = {
contract: {
abiFunction: abiFunction,
redeemScript: hexToBin(contract.bytecode),
artifact: contract.artifact,
},
};
return wcContractObj;

return walletConnectContractObject;
}

/**
Expand All @@ -66,15 +95,19 @@
/**
* Create a placeholder P2PKH `Unlocker` for the provided user address. The returned unlocker
* generates an empty unlocking bytecode and is flagged as `placeholder: true`, which is useful
* when building a transaction object for WalletConnect signing where the final signing is
* performed by the connected wallet.
* when building a transaction object for WalletConnect / WizardConnect signing where the final
* signing is performed by the connected wallet.
*
* @param userAddress - The user's CashAddress that will eventually sign the input.
* @param userAddress - The user's CashAddress that will eventually sign the input
* @param options - Optional signing metadata, such as HD path information for WizardConnect.
* @returns A placeholder unlocker that can be passed to `TransactionBuilder.addInput`.
* @throws If `userAddress` is not a valid CashAddress.
*/
export const placeholderP2PKHUnlocker = (userAddress: string): PlaceholderP2PKHUnlocker => {
export function placeholderP2PKHUnlocker(
userAddress: string,
options: PlaceholderP2PKHUnlockerOptions = {},
): PlaceholderP2PKHUnlocker {
const decodeAddressResult = cashAddressToLockingBytecode(userAddress);

Check failure on line 110 in packages/cashscript/src/walletconnect-utils.ts

View workflow job for this annotation

GitHub Actions / testing

test/TransactionBuilder.test.ts > Transaction Builder > test TransactionBuilder.generateWizardConnectTransactionObject > should generate input paths for placeholder P2PKH inputs with HD path metadata

TypeError: address.toLowerCase is not a function ❯ decodeCashAddressFormat ../../node_modules/@bitauth/libauth/build/lib/address/cash-address.js:452:27 ❯ decodeCashAddressNonStandard ../../node_modules/@bitauth/libauth/build/lib/address/cash-address.js:505:21 ❯ decodeCashAddress ../../node_modules/@bitauth/libauth/build/lib/address/cash-address.js:543:21 ❯ cashAddressToLockingBytecode ../../node_modules/@bitauth/libauth/build/lib/address/locking-bytecode.js:313:21 ❯ placeholderP2PKHUnlocker src/walletconnect-utils.ts:110:31 ❯ test/TransactionBuilder.test.ts:268:34

if (typeof decodeAddressResult === 'string') {
throw new Error(`Invalid address: ${decodeAddressResult}`);
Expand All @@ -85,5 +118,7 @@
generateLockingBytecode: () => lockingBytecode,
generateUnlockingBytecode: () => Uint8Array.from(Array(0)),
placeholder: true,
address: userAddress,
...options,
};
};
}
50 changes: 50 additions & 0 deletions packages/cashscript/test/TransactionBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,56 @@ describe('Transaction Builder', () => {
});
});

describe('test TransactionBuilder.generateWizardConnectTransactionObject', () => {
it('should generate input paths for placeholder P2PKH inputs with HD path metadata', async () => {
const p2pkhUtxos = (await p2pkhInstance.getUtxos()).filter(isNonTokenUtxo).sort(utxoComparator).reverse();
const contractUtxo = p2pkhUtxos[0];
const bobUtxos = await provider.getUtxos(bobAddress);
const carolUtxos = await provider.getUtxos(carolAddress);

const placeholderPubKey = placeholderPublicKey();
const placeholderSig = placeholderSignature();

const transactionBuilder = new TransactionBuilder({ provider })
.addInput(contractUtxo, p2pkhInstance.unlock.spend(placeholderPubKey, placeholderSig))
.addInput(bobUtxos[0], placeholderP2PKHUnlocker(bobAddress, {
hdPath: { name: 'receive', addressIndex: 5 },
}))
.addInput(carolUtxos[0], placeholderP2PKHUnlocker({
address: carolAddress,
hdPath: { name: 'change', addressIndex: 2 },
}))
.addOutput({ to: bobAddress, amount: 100_000n });

const wizardConnectTransactionObj = transactionBuilder.generateWizardConnectTransactionObject({
broadcast: false,
userPrompt: 'Example WizardConnect transaction',
});

expect(wizardConnectTransactionObj.transaction).toMatchObject({
broadcast: false,
userPrompt: 'Example WizardConnect transaction',
});
expect(wizardConnectTransactionObj.transaction.sourceOutputs).toHaveLength(3);
expect(wizardConnectTransactionObj.inputPaths).toEqual([
[1, 'receive', 5],
[2, 'change', 2],
]);
});

it('should fail when a placeholder P2PKH input is missing HD path metadata', async () => {
const bobUtxos = await provider.getUtxos(bobAddress);

const transactionBuilder = new TransactionBuilder({ provider })
.addInput(bobUtxos[0], placeholderP2PKHUnlocker(bobAddress))
.addOutput({ to: bobAddress, amount: 100_000n });

expect(() => transactionBuilder.generateWizardConnectTransactionObject()).toThrow(
'Placeholder P2PKH input 0 is missing WizardConnect HD path metadata',
);
});
});

it('should not fail when validly spending from only P2PKH inputs', async () => {
const aliceUtxos = (await provider.getUtxos(aliceAddress)).filter(isNonTokenUtxo);
const sigTemplate = new SignatureTemplate(alicePriv);
Expand Down
Loading
Loading