diff --git a/packages/cashscript/src/TransactionBuilder.ts b/packages/cashscript/src/TransactionBuilder.ts index 497287c5..80aef7c1 100644 --- a/packages/cashscript/src/TransactionBuilder.ts +++ b/packages/cashscript/src/TransactionBuilder.ts @@ -1,6 +1,7 @@ import { binToHex, decodeTransaction, + decodeTransactionUnsafe, encodeTransaction, hexToBin, Transaction as LibauthTransaction, @@ -22,6 +23,7 @@ import { NetworkProvider } from './network/index.js'; import { cashScriptOutputToLibauthOutput, createOpReturnOutput, + generateLibauthSourceOutputs, validateInput, validateOutput, } from './utils.js'; @@ -29,7 +31,9 @@ import { FailedTransactionError } from './Errors.js'; import { DebugResults } from './debugging.js'; import { getBitauthUri } from './LibauthTemplate.js'; import { debugLibauthTemplate, getLibauthTemplates } from './advanced/LibauthTemplate.js'; +import { getWcContractInfo, WcSourceOutput, WcTransactionOptions } from './walletconnect-utils.js'; import semver from 'semver'; +import { WcTransactionObject } from './walletconnect-utils.js'; export interface TransactionBuilderOptions { provider: NetworkProvider; @@ -134,15 +138,7 @@ export class TransactionBuilder { }; // Generate source outputs from inputs (for signing with SIGHASH_UTXOS) - const sourceOutputs = this.inputs.map((input) => { - const sourceOutput = { - amount: input.satoshis, - to: input.unlocker.generateLockingBytecode(), - token: input.token, - }; - - return cashScriptOutputToLibauthOutput(sourceOutput); - }); + const sourceOutputs = generateLibauthSourceOutputs(this.inputs); const inputScripts = this.inputs.map((input, inputIndex) => ( input.unlocker.generateUnlockingBytecode({ transaction, sourceOutputs, inputIndex }) @@ -226,4 +222,24 @@ export class TransactionBuilder { // Should not happen throw new Error('Could not retrieve transaction details for over 10 minutes'); } + + generateWcTransactionObject(options?: WcTransactionOptions): WcTransactionObject { + const inputs = this.inputs; + if (!inputs.every(input => isStandardUnlockableUtxo(input))) { + throw new Error('All inputs must be StandardUnlockableUtxos to generate the wcSourceOutputs'); + } + + const encodedTransaction = this.build(); + const transaction = decodeTransactionUnsafe(hexToBin(encodedTransaction)); + + const libauthSourceOutputs = generateLibauthSourceOutputs(inputs); + const sourceOutputs: WcSourceOutput[] = libauthSourceOutputs.map((sourceOutput, index) => { + return { + ...sourceOutput, + ...transaction.inputs[index], + ...getWcContractInfo(inputs[index]), + }; + }); + return { ...options, transaction, sourceOutputs }; + } } diff --git a/packages/cashscript/src/index.ts b/packages/cashscript/src/index.ts index 53c364bb..a1e87529 100644 --- a/packages/cashscript/src/index.ts +++ b/packages/cashscript/src/index.ts @@ -21,3 +21,4 @@ export { MockNetworkProvider, } from './network/index.js'; export { randomUtxo, randomToken, randomNFT } from './utils.js'; +export * from './walletconnect-utils.js'; diff --git a/packages/cashscript/src/utils.ts b/packages/cashscript/src/utils.ts index 26699d88..0771083c 100644 --- a/packages/cashscript/src/utils.ts +++ b/packages/cashscript/src/utils.ts @@ -32,6 +32,7 @@ import { LibauthOutput, TokenDetails, AddressType, + UnlockableUtxo, } from './interfaces.js'; import { VERSION_SIZE, LOCKTIME_SIZE } from './constants.js'; import { @@ -123,6 +124,19 @@ export function libauthOutputToCashScriptOutput(output: LibauthOutput): Output { }; } +export function generateLibauthSourceOutputs(inputs: UnlockableUtxo[]): LibauthOutput[] { + const sourceOutputs = inputs.map((input) => { + const sourceOutput = { + amount: input.satoshis, + to: input.unlocker.generateLockingBytecode(), + token: input.token, + }; + + return cashScriptOutputToLibauthOutput(sourceOutput); + }); + return sourceOutputs; +} + function isTokenAddress(address: string): boolean { const result = decodeCashAddress(address); if (typeof result === 'string') throw new Error(result); diff --git a/packages/cashscript/src/walletconnect-utils.ts b/packages/cashscript/src/walletconnect-utils.ts new file mode 100644 index 00000000..4d25606b --- /dev/null +++ b/packages/cashscript/src/walletconnect-utils.ts @@ -0,0 +1,64 @@ +import type { StandardUnlockableUtxo, LibauthOutput, Unlocker } from './interfaces.js'; +import { type AbiFunction, type Artifact, scriptToBytecode } from '@cashscript/utils'; +import { cashAddressToLockingBytecode, 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 { + broadcast?: boolean; + userPrompt?: string; +} + +export interface WcTransactionObject { + transaction: TransactionCommon | string; + sourceOutputs: WcSourceOutput[]; + broadcast?: boolean; + userPrompt?: string; +} + +export type WcSourceOutput = Input & LibauthOutput & WcContractInfo; + +export interface WcContractInfo { + contract?: { + abiFunction: AbiFunction; + redeemScript: Uint8Array; + artifact: Partial; + } +} + +export function getWcContractInfo(input: StandardUnlockableUtxo): WcContractInfo | {} { + // If the input does not have a contract unlocker, return an empty object + if (!('contract' in 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 = { + contract: { + abiFunction: abiFunction, + redeemScript: scriptToBytecode(contract.redeemScript), + artifact: contract.artifact, + }, + }; + return wcContractObj; +} + +export const placeholderSignature = (): Uint8Array => Uint8Array.from(Array(65)); +export const placeholderPublicKey = (): Uint8Array => Uint8Array.from(Array(33)); + +export const placeholderP2PKHUnlocker = (userAddress: string): Unlocker => { + const decodeAddressResult = cashAddressToLockingBytecode(userAddress); + + if (typeof decodeAddressResult === 'string') { + throw new Error(`Invalid address: ${decodeAddressResult}`); + } + + const lockingBytecode = decodeAddressResult.bytecode; + return { + generateLockingBytecode: () => lockingBytecode, + generateUnlockingBytecode: () => Uint8Array.from(Array(0)), + }; +}; diff --git a/website/docs/guides/walletconnect.md b/website/docs/guides/walletconnect.md index 7cca98a9..d1864c9d 100644 --- a/website/docs/guides/walletconnect.md +++ b/website/docs/guides/walletconnect.md @@ -16,75 +16,66 @@ Most relevant for smart contract usage is the BCH-WalletConnect `signTransaction > This is a most generic interface to propose a bitcoincash transaction to a wallet which reconstructs it and signs it on behalf of the wallet user. -```typescript -signTransaction: ( - options: { - transaction: string | TransactionBCH, - sourceOutputs: (Input | Output | ContractInfo)[], - broadcast?: boolean, - userPrompt?: string - } -) => Promise<{ signedTransaction: string, signedTransactionHash: string } | undefined>; +```ts +signTransaction: (wcTransactionObj: WcTransactionObject) => Promise; ``` -You can see that the CashScript `ContractInfo` needs to be provided as part of the `sourceOutputs`. Important to note from the spec is how the wallet knows which inputs to sign: - ->To signal that the wallet needs to sign an input, the app sets the corresponding input's `unlockingBytecode` to empty Uint8Array. +```ts +interface WcTransactionObject { + transaction: TransactionCommon | string; + sourceOutputs: WcSourceOutput[]; + broadcast?: boolean; + userPrompt?: string; +} -Also important for smart contract usage is how the wallet adds the public-key or a signature to contract inputs: +type WcSourceOutput = Input & Output & WcContractInfo; -> We signal the use of pubkeys by using a 33-byte long zero-filled arrays and schnorr (the currently supported type) signatures by using a 65-byte long zero-filled arrays. Wallet detects these patterns and replaces them accordingly. +interface WcContractInfo { + contract?: { + abiFunction: AbiFunction; + redeemScript: Uint8Array; + artifact: Partial; + } +} -## Create wcTransactionObj +interface SignedTxObject { + signedTransaction: string; + signedTransactionHash: string; +} +``` -To use the BCH WalletConnect `signTransaction` API, we need to pass an `options` object which we'll call `wcTransactionObj`. +To use the BCH WalletConnect `signTransaction` API, we need to pass a `wcTransactionObj`. +CashScript `TransactionBuilder` has a `generateWcTransactionObject` method for creating this object. -Below we'll give 2 example, the first example using spending a user-input and in the second example spending from a user-contract with the `userPubKey` and the `userSig` +Below we show 2 examples, the first example using spending a user-input and in the second example spending from a user-contract with placeholders for `userPubKey` and `userSig` ### Spending a user-input Below is example code from the `CreateContract` code of the 'Hodl Vault' dapp repository, [link to source code](https://github.com/mr-zwets/bch-hodl-dapp/blob/main/src/views/CreateContract.vue#L14). ```ts -import { Contract } from "cashscript"; +import { TransactionBuilder, placeholderP2PKHUnlocker } from "cashscript"; import { hexToBin, decodeTransaction } from "@bitauth/libauth"; -async function proposeWcTransaction(){ - // create a placeholderUnlocker for the empty signature - const placeholderUnlocker: Unlocker = { - generateLockingBytecode: () => convertPkhToLockingBytecode(userPkh), - generateUnlockingBytecode: () => Uint8Array.from(Array(0)) - } +async function proposeWcTransaction(userAddress: string){ + // use a placeholderUnlocker which will be replaced by the user's wallet + const placeholderUnlocker = placeholderP2PKHUnlocker(userAddress) - // use the CashScript SDK to build a transaction + // use the CashScript SDK to construct a transaction const transactionBuilder = new TransactionBuilder({provider: store.provider}) transactionBuilder.addInputs(userInputUtxos, placeholderUnlocker) transactionBuilder.addOpReturnOutput(opReturnData) transactionBuilder.addOutput(contractOutput) if(changeAmount > 550n) transactionBuilder.addOutput(changeOutput) - const unsignedRawTransactionHex = await transactionBuilder.build(); - - const decodedTransaction = decodeTransaction(hexToBin(unsignedRawTransactionHex)); - if(typeof decodedTransaction == "string") throw new Error("!decodedTransaction") - - // construct SourceOutputs from transaction input, see source code - const sourceOutputs = generateSourceOutputs(transactionBuilder.inputs) - - // we don't need to add the contractInfo to the wcSourceOutputs here - const wcSourceOutputs = sourceOutputs.map((sourceOutput, index) => { - return { ...sourceOutput, ...decodedTransaction.inputs[index] } - }) - - // wcTransactionObj to pass to signTransaction endpoint - const wcTransactionObj = { - transaction: decodedTransaction, - sourceOutputs: listSourceOutputs, + // Generate WalletConnect transaction object with custom 'broadcast' and 'userPrompt' options + const wcTransactionObj = transactionBuilder.generateWcTransactionObject({ broadcast: true, - userPrompt: "Create HODL Contract" - }; + userPrompt: "Create HODL Contract", + }); // pass wcTransactionObj to WalletConnect client + // (see signWcTransaction implementation below) const signResult = await signWcTransaction(wcTransactionObj); // Handle signResult success / failure @@ -96,13 +87,13 @@ async function proposeWcTransaction(){ Below is example code from the `unlockHodlVault` code of the 'Hodl Vault' dapp repository, [link to source code](https://github.com/mr-zwets/bch-hodl-dapp/blob/main/src/views/UserContracts.vue#L66). ```ts -import { Contract } from "cashscript"; +import { TransactionBuilder, placeholderSignature, placeholderPublicKey } from "cashscript"; import { hexToBin, decodeTransaction } from "@bitauth/libauth"; async function unlockHodlVault(){ - // create a placeholder for the unlocking arguments - const placeholderSig = Uint8Array.from(Array(65)) - const placeholderPubKey = Uint8Array.from(Array(33)); + // We use a placeholder signature and public key so this can be filled in by the user's wallet + const placeholderSig = placeholderSignature() + const placeholderPubKey = placeholderPublicKey() const transactionBuilder = new TransactionBuilder({provider: store.provider}) @@ -110,27 +101,14 @@ async function unlockHodlVault(){ transactionBuilder.addInputs(contractUtxos, hodlContract.unlock.spend(placeholderPubKey, placeholderSig)) transactionBuilder.addOutput(reclaimOutput) - const unsignedRawTransactionHex = transactionBuilder.build(); - - const decodedTransaction = decodeTransaction(hexToBin(unsignedRawTransactionHex)); - if(typeof decodedTransaction == "string") throw new Error("!decodedTransaction") - - const sourceOutputs = generateSourceOutputs(transactionBuilder.inputs) - - // Add the contractInfo to the wcSourceOutputs - const wcSourceOutputs: wcSourceOutputs = sourceOutputs.map((sourceOutput, index) => { - const contractInfoWc = createWcContractObj(hodlContract, index) - return { ...sourceOutput, ...contractInfoWc, ...decodedTransaction.inputs[index] } - }) - - const wcTransactionObj = { - transaction: decodedTransaction, - sourceOutputs: wcSourceOutputs, + // Generate WalletConnect transaction object with custom 'broadcast' and 'userPrompt' options + const wcTransactionObj = transactionBuilder.generateWcTransactionObject({ broadcast: true, userPrompt: "Reclaim HODL Value", - }; + }); // pass wcTransactionObj to WalletConnect client + // (see signWcTransaction implementation below) const signResult = await signWcTransaction(wcTransactionObj); // Handle signResult success / failure @@ -146,13 +124,14 @@ See [the source code](https://github.com/mr-zwets/bch-hodl-dapp/blob/main/src/st ```ts import SignClient from '@walletconnect/sign-client'; import { stringify } from "@bitauth/libauth"; +import { type WcTransactionObject } from "cashscript"; -interface signedTxObject { +interface SignedTxObject { signedTransaction: string; signedTransactionHash: string; } -async function signWcTransaction(wcTransactionObj): signedTxObject | undefined { +async function signWcTransaction(wcTransactionObj: WcTransactionObject): SignedTxObject | undefined { try { const result = await signClient.request({ chainId: connectedChain, diff --git a/website/docs/releases/release-notes.md b/website/docs/releases/release-notes.md index e202e940..445ca2f7 100644 --- a/website/docs/releases/release-notes.md +++ b/website/docs/releases/release-notes.md @@ -2,6 +2,12 @@ title: Release Notes --- +## v0.11.1 + +#### CashScript SDK +- :sparkles: Add `generateWcTransactionObject()` method to `TransactionBuilder` to generate a `WcTransactionObject` that can be used to sign a transaction with a WalletConnect client. +- :sparkles: Add `placeholderSignature()`, `placeholderPublicKey()` and `placeholderP2PKHUnlocker()` helper functions to the SDK for WalletConnect usage. + ## v0.11.0 This update adds CashScript support for the new BCH 2025 network upgrade. To read more about the upgrade, see [this blog post](https://blog.bitjson.com/2025-chips/). diff --git a/website/docs/sdk/transaction-builder.md b/website/docs/sdk/transaction-builder.md index 6080d662..b3261eb1 100644 --- a/website/docs/sdk/transaction-builder.md +++ b/website/docs/sdk/transaction-builder.md @@ -250,6 +250,73 @@ You can read more about debugging transactions on the [debugging page](/docs/gui It is unsafe to debug transactions on mainnet using the BitAuth IDE as private keys will be exposed to BitAuth IDE and transmitted over the network. ::: +### generateWcTransactionObject() +```ts +transactionBuilder.generateWcTransactionObject(options?: WcTransactionOptions): WcTransactionObject +``` + +Generates a `WcTransactionObject` that can be used to sign a transaction with a WalletConnect client. It accepts an optional `WcTransactionOptions` object to customize the transaction object with custom `broadcast` and `userPrompt` properties. + +```ts +import type { TransactionCommon, Input, Output } from '@bitauth/libauth'; +import type { AbiFunction, Artifact } from 'cashscript'; + +interface WcTransactionOptions { + broadcast?: boolean; + userPrompt?: string; +} + +interface WcTransactionObject { + transaction: TransactionCommon | string; + sourceOutputs: WcSourceOutput[]; + broadcast?: boolean; + userPrompt?: string; +} + +type WcSourceOutput = Input & Output & WcContractInfo; + +interface WcContractInfo { + contract?: { + abiFunction: AbiFunction; + redeemScript: Uint8Array; + artifact: Partial; + } +} +``` + +:::tip +See the [WalletConnect guide](/docs/guides/walletconnect) for more information on how to use the `WcTransactionObject` with a WalletConnect client. +::: + +#### Example +```ts +import { aliceAddress, contract, provider, signWcTransaction } from './somewhere.js'; +import { TransactionBuilder, placeholderP2PKHUnlocker, placeholderPublicKey, placeholderSignature } from 'cashscript'; + +const contractUtxos = await contract.getUtxos(); +const aliceUtxos = await provider.getUtxos(aliceAddress); + +// Use placeholder variables which will be replaced by the user's wallet when signing the transaction with WalletConnect +const placeholderUnlocker = placeholderP2PKHUnlocker(aliceAddress); +const placeholderPubKey = placeholderPublicKey(); +const placeholderSig = placeholderSignature(); + +// use the CashScript SDK to construct a transaction +const transactionBuilder = new TransactionBuilder({ provider }) + .addInput(contractUtxos[0], contract.unlock.spend(placeholderPubKey, placeholderSig)) + .addInput(aliceUtxos[0], placeholderUnlocker) + .addOutput({ to: aliceAddress, amount: 100_000n }); + +// Generate WalletConnect transaction object with custom 'broadcast' and 'userPrompt' options +const wcTransactionObj = transactionBuilder.generateWcTransactionObject({ + broadcast: true, + userPrompt: "Example Contract transaction", +}); + +// Pass wcTransactionObj to WalletConnect client (see WalletConnect guide for more details) +const signResult = await signWcTransaction(wcTransactionObj); +``` + ## Transaction errors Transactions can fail for a number of reasons. Refer to the [Transaction Errors][transactions-simple-errors] section of the simplified transaction builder documentation for more information. Note that the transaction builder does not yet support the `FailedRequireError` mentioned in the simplified transaction builder documentation so any error will be of type `FailedTransactionError` and include any of the mentioned error reasons in its message.