Skip to content
Draft
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
65 changes: 53 additions & 12 deletions packages/cashscript/src/TransactionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ import {
isStandardUnlockableUtxo,
StandardUnlockableUtxo,
VmResourceUsage,
isContractUnlocker,
BchChangeOutputOptions,
TokenChangeOutputOptions,
} from './interfaces.js';
isContractUnlocker,
BchChangeOutputOptions,
TokenChangeOutputOptions,
isPlaceholderUnlocker,
} from './interfaces.js';
import { NetworkProvider } from './network/index.js';
import {
calculateDust,
Expand All @@ -36,7 +37,13 @@ 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,
WcSourceOutput,
WcTransactionOptions,
WizardConnectInputPath,
WizardConnectTransactionObject,
} from './walletconnect-utils.js';
import semver from 'semver';
import { WcTransactionObject } from './walletconnect-utils.js';

Expand Down Expand Up @@ -561,9 +568,9 @@ 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 {
const encodedTransaction = this.build();
const transaction = decodeTransactionUnsafe(hexToBin(encodedTransaction));
generateWcTransactionObject(options?: WcTransactionOptions): WcTransactionObject {
const encodedTransaction = this.build();
const transaction = decodeTransactionUnsafe(hexToBin(encodedTransaction));

const libauthSourceOutputs = generateLibauthSourceOutputs(this.inputs);
const sourceOutputs: WcSourceOutput[] = libauthSourceOutputs.map((sourceOutput, index) => {
Expand All @@ -572,7 +579,41 @@ export class TransactionBuilder {
...transaction.inputs[index],
...getWcContractInfo(this.inputs[index]),
};
});
return { ...options, transaction, sourceOutputs };
}
}
});
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?: WcTransactionOptions): WizardConnectTransactionObject {
const transaction = this.generateWcTransactionObject(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;
}
}
19 changes: 18 additions & 1 deletion packages/cashscript/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,24 @@ 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 PlaceholderP2PKHUnlockerConfig extends PlaceholderP2PKHUnlockerOptions {
address: string;
}

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

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

Expand Down
38 changes: 34 additions & 4 deletions packages/cashscript/src/walletconnect-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { type LibauthOutput, isContractUnlocker, type PlaceholderP2PKHUnlocker, type UnlockableUtxo } from './interfaces.js';
import {
type LibauthOutput,
isContractUnlocker,
type PlaceholderP2PKHUnlockerConfig,
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';

Expand All @@ -17,6 +24,13 @@ export interface WcTransactionObject {
userPrompt?: string;
}

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

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

export type WcSourceOutput = Input & LibauthOutput & WcContractInfo;

export interface WcContractInfo {
Expand Down Expand Up @@ -69,11 +83,25 @@ export const placeholderPublicKey = (): Uint8Array => Uint8Array.from(Array(33))
* when building a transaction object for WalletConnect 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, or an object
* containing the address and signing metadata.
* @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;
export function placeholderP2PKHUnlocker(options: PlaceholderP2PKHUnlockerConfig): PlaceholderP2PKHUnlocker;
export function placeholderP2PKHUnlocker(
userAddressOrOptions: string | PlaceholderP2PKHUnlockerConfig,
options: PlaceholderP2PKHUnlockerOptions = {},
): PlaceholderP2PKHUnlocker {
const userAddress = typeof userAddressOrOptions === 'string'
? userAddressOrOptions
: userAddressOrOptions.address;
const unlockerOptions = typeof userAddressOrOptions === 'string' ? options : userAddressOrOptions;
const decodeAddressResult = cashAddressToLockingBytecode(userAddress);

if (typeof decodeAddressResult === 'string') {
Expand All @@ -85,5 +113,7 @@ export const placeholderP2PKHUnlocker = (userAddress: string): PlaceholderP2PKHU
generateLockingBytecode: () => lockingBytecode,
generateUnlockingBytecode: () => Uint8Array.from(Array(0)),
placeholder: true,
address: userAddress,
...unlockerOptions,
};
};
}
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