diff --git a/packages/cashscript/src/Argument.ts b/packages/cashscript/src/Argument.ts index 915fe84d..65d515d9 100644 --- a/packages/cashscript/src/Argument.ts +++ b/packages/cashscript/src/Argument.ts @@ -11,12 +11,13 @@ import { } from '@cashscript/utils'; import { TypeError } from './Errors.js'; import SignatureTemplate from './SignatureTemplate.js'; +import PlaceholderTemplate from './PlaceholderTemplate.js'; export type ConstructorArgument = bigint | boolean | string | Uint8Array; -export type FunctionArgument = ConstructorArgument | SignatureTemplate; +export type FunctionArgument = ConstructorArgument | SignatureTemplate | PlaceholderTemplate; export type EncodedConstructorArgument = Uint8Array; -export type EncodedFunctionArgument = Uint8Array | SignatureTemplate; +export type EncodedFunctionArgument = Uint8Array | SignatureTemplate | PlaceholderTemplate; export type EncodeFunction = (arg: FunctionArgument, typeStr: string) => EncodedFunctionArgument; diff --git a/packages/cashscript/src/Contract.ts b/packages/cashscript/src/Contract.ts index e2baf3d2..43415a7f 100644 --- a/packages/cashscript/src/Contract.ts +++ b/packages/cashscript/src/Contract.ts @@ -25,6 +25,7 @@ import SignatureTemplate from './SignatureTemplate.js'; import { ElectrumNetworkProvider } from './network/index.js'; import { ParamsToTuple, AbiToFunctionMap } from './types/type-inference.js'; import semver from 'semver'; +import PlaceholderTemplate from './PlaceholderTemplate.js'; export class Contract< TArtifact extends Artifact = Artifact, @@ -161,6 +162,7 @@ export class Contract< { transaction, sourceOutputs, inputIndex }: GenerateUnlockingBytecodeOptions, ): Uint8Array => { const completeArgs = encodedArgs.map((arg) => { + if (arg instanceof PlaceholderTemplate) return arg.generateSignature(); if (!(arg instanceof SignatureTemplate)) return arg; // Generate transaction signature from SignatureTemplate diff --git a/packages/cashscript/src/PlaceholderTemplate.ts b/packages/cashscript/src/PlaceholderTemplate.ts new file mode 100644 index 00000000..4094e035 --- /dev/null +++ b/packages/cashscript/src/PlaceholderTemplate.ts @@ -0,0 +1,54 @@ +import { cashAddressToLockingBytecode } from '@bitauth/libauth'; +import { Unlocker } from './interfaces.js'; +import SignatureTemplate from './SignatureTemplate.js'; + +export default class PlaceholderTemplate { + public privateKey: Uint8Array; + private lockingBytecode: Uint8Array; + + constructor( + address: string, + ) { + const decodeAddressResult = cashAddressToLockingBytecode(address); + if (typeof decodeAddressResult === 'string') { + throw new Error(`Invalid address: ${decodeAddressResult}`); + } + this.lockingBytecode = decodeAddressResult.bytecode; + } + + // TODO: should the arguments 'generateSignature' match? + // do the other methods (getHashType, getSignatureAlgorithm) need to be implemented? + + // Currently in the walletconnect spec, only schnorr (65-byte) signatures are supported + generateSignature(): Uint8Array { + return Uint8Array.from(Array(65)); + } + + getPublicKey(): Uint8Array { + return Uint8Array.from(Array(33)); + } + + unlockP2PKH(): Unlocker { + return { + generateLockingBytecode: () => this.lockingBytecode, + generateUnlockingBytecode: () => Uint8Array.from(Array(0)), + }; + } +} + +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/packages/cashscript/src/TransactionBuilder.ts b/packages/cashscript/src/TransactionBuilder.ts index 497287c5..f3f4bbd9 100644 --- a/packages/cashscript/src/TransactionBuilder.ts +++ b/packages/cashscript/src/TransactionBuilder.ts @@ -22,6 +22,7 @@ import { NetworkProvider } from './network/index.js'; import { cashScriptOutputToLibauthOutput, createOpReturnOutput, + generateLibauthSourceOutputs, validateInput, validateOutput, } from './utils.js'; @@ -134,15 +135,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 }) diff --git a/packages/cashscript/src/index.ts b/packages/cashscript/src/index.ts index 53c364bb..bc733cdc 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 './wc-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/wc-utils.ts b/packages/cashscript/src/wc-utils.ts new file mode 100644 index 00000000..c4917e4c --- /dev/null +++ b/packages/cashscript/src/wc-utils.ts @@ -0,0 +1,82 @@ +import { isStandardUnlockableUtxo } from './index.js'; +import type { UnlockableUtxo, StandardUnlockableUtxo, LibauthOutput } from './interfaces.js'; +import { generateLibauthSourceOutputs } from './utils.js'; +import { type AbiFunction, type Artifact, scriptToBytecode } from '@cashscript/utils'; +import { decodeTransactionUnsafe, 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 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; + } +} + +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 function generateWcTransactionObject( + inputs: UnlockableUtxo[], encodedTransaction: string, +): WcTransactionObject { + if (!inputs.every(input => isStandardUnlockableUtxo(input))) { + throw new Error('All inputs must be StandardUnlockableUtxos to generate the wcSourceOutputs'); + } + + 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 { transaction, sourceOutputs }; +} + +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..b6691993 100644 --- a/website/docs/guides/walletconnect.md +++ b/website/docs/guides/walletconnect.md @@ -27,13 +27,9 @@ signTransaction: ( ) => Promise<{ signedTransaction: string, signedTransactionHash: string } | undefined>; ``` -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: +The `transaction` passed in this object needs to be an unsigned transaction which is using placeholder values for the field that the user-wallet needs to fill in itself. You can use the `PlaceholderTemplate` class to generate the zero-placeholder values. ->To signal that the wallet needs to sign an input, the app sets the corresponding input's `unlockingBytecode` to empty Uint8Array. - -Also important for smart contract usage is how the wallet adds the public-key or a signature to contract inputs: - -> 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. +The `sourceOutputs` value can be easily generated with the CashScript `generateWcSourceOutputs` helperfunction. ## Create wcTransactionObj @@ -46,40 +42,26 @@ Below we'll give 2 example, the first example using spending a user-input and in 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, generateWcSourceOutputs, placeholderTemplate } 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){ + // Create a placeholderTemplate + const unlocker = new PlaceholderTemplate(userAddress).unlockP2PKH() + const unlocker = getPlaceholderP2PKHUnlocker(userAddress) - // use the CashScript SDK to build a transaction + // Use the CashScript SDK to build a transaction const transactionBuilder = new TransactionBuilder({provider: store.provider}) - transactionBuilder.addInputs(userInputUtxos, placeholderUnlocker) + transactionBuilder.addInputs(userInputUtxos, getPlaceholderP2PKHUnlocker(userAddress)) 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") + const rawTransactionHex = await transactionBuilder.build(); - // 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 + // Combine the generated WalletConnect transaction object with custom 'broadcast' and 'userPrompt' properties const wcTransactionObj = { - transaction: decodedTransaction, - sourceOutputs: listSourceOutputs, + ...generateWcTransactionObject(transactionBuilder.inputs, rawTransactionHex), broadcast: true, userPrompt: "Create HODL Contract" }; @@ -96,36 +78,30 @@ 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, generateWcSourceOutputs, PlaceholderTemplate } 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)); +async function unlockHodlVault() { + // use placeholderTemplate to create placeholder args for unlocker + const placeholderTemplate = new PlaceholderTemplate() + const placeholderSig = placeholderTemplate.generateSignature() + const placeholderPubKey = placeholderTemplate.getPublicKey() const transactionBuilder = new TransactionBuilder({provider: store.provider}) transactionBuilder.setLocktime(store.currentBlockHeight) - transactionBuilder.addInputs(contractUtxos, hodlContract.unlock.spend(placeholderPubKey, placeholderSig)) + transactionBuilder.addInputs(contractUtxos, hodlContract.unlock.spend(getPlaceholderPubKey(), getPlaceholderTemplate())) 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) + const rawTransactionHex = transactionBuilder.build(); - // Add the contractInfo to the wcSourceOutputs - const wcSourceOutputs: wcSourceOutputs = sourceOutputs.map((sourceOutput, index) => { - const contractInfoWc = createWcContractObj(hodlContract, index) - return { ...sourceOutput, ...contractInfoWc, ...decodedTransaction.inputs[index] } - }) + // Generate the WalletConnect source outputs and raw transaction object + const { sourceOutputs, transaction } = generateWcTransactionObject(transactionBuilder.inputs, rawTransactionHex); + // Combine the source outputs and raw transaction object with custom 'broadcast' and 'userPrompt' properties const wcTransactionObj = { - transaction: decodedTransaction, - sourceOutputs: wcSourceOutputs, + transaction, + sourceOutputs, broadcast: true, userPrompt: "Reclaim HODL Value", };