From 260f2d58577c92ef37d0f1d276e1e0fc0fd267b3 Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 4 Sep 2023 23:31:53 +0400 Subject: [PATCH] feat: add multisig support to contract deploys (#1539) * feat: multisig support for contract deploy transactions * fix: contract deploy options type removed * chore: add backward compatibility * test: add origin verification to tests --------- Co-authored-by: janniks <6362150+janniks@users.noreply.github.com> Co-authored-by: janniks --- packages/cli/src/cli.ts | 4 +- packages/transactions/src/builders.ts | 95 +++++++++++++++------ packages/transactions/tests/builder.test.ts | 56 ++++++++++-- 3 files changed, 120 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 73887da1..b6cdb9db 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -18,7 +18,7 @@ import { ClarityAbi, ClarityValue, ContractCallPayload, - ContractDeployOptions, + SignedContractDeployOptions, createStacksPrivateKey, cvToString, estimateContractDeploy, @@ -766,7 +766,7 @@ async function contractDeploy(network: CLINetworkAdapter, args: string[]): Promi ? new StacksMainnet({ url: network.legacyNetwork.blockstackAPIUrl }) : new StacksTestnet({ url: network.legacyNetwork.blockstackAPIUrl }); - const options: ContractDeployOptions = { + const options: SignedContractDeployOptions = { contractName, codeBody: source, senderKey: privateKey, diff --git a/packages/transactions/src/builders.ts b/packages/transactions/src/builders.ts index 9fb6b207..3b6b7210 100644 --- a/packages/transactions/src/builders.ts +++ b/packages/transactions/src/builders.ts @@ -38,7 +38,6 @@ import { ClarityAbi, validateContractCall } from './contract-abi'; import { NoEstimateAvailableError } from './errors'; import { createStacksPrivateKey, - createStacksPublicKey, getPublicKey, pubKeyfromPrivKey, publicKeyFromBytes, @@ -707,16 +706,29 @@ export interface BaseContractDeployOptions { sponsored?: boolean; } -export interface ContractDeployOptions extends BaseContractDeployOptions { - /** a hex string of the private key of the transaction sender */ - senderKey: string; -} - export interface UnsignedContractDeployOptions extends BaseContractDeployOptions { /** a hex string of the public key of the transaction sender */ publicKey: string; } +export interface SignedContractDeployOptions extends BaseContractDeployOptions { + senderKey: string; +} + +/** @deprecated Use {@link SignedContractDeployOptions} or {@link UnsignedContractDeployOptions} instead. */ +export interface ContractDeployOptions extends SignedContractDeployOptions {} + +export interface UnsignedMultiSigContractDeployOptions extends BaseContractDeployOptions { + numSignatures: number; + publicKeys: string[]; +} + +export interface SignedMultiSigContractDeployOptions extends BaseContractDeployOptions { + numSignatures: number; + publicKeys: string[]; + signerKeys: string[]; +} + /** * @deprecated Use the new {@link estimateTransaction} function insterad. * @@ -772,31 +784,49 @@ export async function estimateContractDeploy( /** * Generates a Clarity smart contract deploy transaction * - * @param {ContractDeployOptions} txOptions - an options object for the contract deploy + * @param {SignedContractDeployOptions | SignedMultiSigContractDeployOptions} txOptions - an options object for the contract deploy * * Returns a signed Stacks smart contract deploy transaction. * * @return {StacksTransaction} */ export async function makeContractDeploy( - txOptions: ContractDeployOptions + txOptions: SignedContractDeployOptions | SignedMultiSigContractDeployOptions ): Promise { - const privKey = createStacksPrivateKey(txOptions.senderKey); - const stacksPublicKey = getPublicKey(privKey); - const publicKey = publicKeyToString(stacksPublicKey); - const unsignedTxOptions: UnsignedContractDeployOptions = { ...txOptions, publicKey }; - const transaction: StacksTransaction = await makeUnsignedContractDeploy(unsignedTxOptions); + if ('senderKey' in txOptions) { + // txOptions is SignedContractDeployOptions + const publicKey = publicKeyToString(getPublicKey(createStacksPrivateKey(txOptions.senderKey))); + const options = omit(txOptions, 'senderKey'); + const transaction = await makeUnsignedContractDeploy({ publicKey, ...options }); - if (txOptions.senderKey) { + const privKey = createStacksPrivateKey(txOptions.senderKey); const signer = new TransactionSigner(transaction); signer.signOrigin(privKey); - } - return transaction; + return transaction; + } else { + // txOptions is SignedMultiSigContractDeployOptions + const options = omit(txOptions, 'signerKeys'); + const transaction = await makeUnsignedContractDeploy(options); + + const signer = new TransactionSigner(transaction); + let pubKeys = txOptions.publicKeys; + for (const key of txOptions.signerKeys) { + const pubKey = pubKeyfromPrivKey(key); + pubKeys = pubKeys.filter(pk => pk !== bytesToHex(pubKey.data)); + signer.signOrigin(createStacksPrivateKey(key)); + } + + for (const key of pubKeys) { + signer.appendOrigin(publicKeyFromBytes(hexToBytes(key))); + } + + return transaction; + } } export async function makeUnsignedContractDeploy( - txOptions: UnsignedContractDeployOptions + txOptions: UnsignedContractDeployOptions | UnsignedMultiSigContractDeployOptions ): Promise { const defaultOptions = { fee: BigInt(0), @@ -815,17 +845,28 @@ export async function makeUnsignedContractDeploy( options.clarityVersion ); - const addressHashMode = AddressHashMode.SerializeP2PKH; - const pubKey = createStacksPublicKey(options.publicKey); - let authorization: Authorization | null = null; - const spendingCondition = createSingleSigSpendingCondition( - addressHashMode, - publicKeyToString(pubKey), - options.nonce, - options.fee - ); + let spendingCondition: SpendingCondition | null = null; + + if ('publicKey' in options) { + // single-sig + spendingCondition = createSingleSigSpendingCondition( + AddressHashMode.SerializeP2PKH, + options.publicKey, + options.nonce, + options.fee + ); + } else { + // multi-sig + spendingCondition = createMultiSigSpendingCondition( + AddressHashMode.SerializeP2SH, + options.numSignatures, + options.publicKeys, + options.nonce, + options.fee + ); + } if (options.sponsored) { authorization = createSponsoredAuth(spendingCondition); @@ -863,7 +904,7 @@ export async function makeUnsignedContractDeploy( options.network.version === TransactionVersion.Mainnet ? AddressVersion.MainnetSingleSig : AddressVersion.TestnetSingleSig; - const senderAddress = publicKeyToAddress(addressVersion, pubKey); + const senderAddress = c32address(addressVersion, transaction.auth.spendingCondition!.signer); const txNonce = await getNonce(senderAddress, options.network); transaction.setNonce(txNonce); } diff --git a/packages/transactions/tests/builder.test.ts b/packages/transactions/tests/builder.test.ts index 9e6371c2..b6d7eefd 100644 --- a/packages/transactions/tests/builder.test.ts +++ b/packages/transactions/tests/builder.test.ts @@ -183,6 +183,7 @@ test('Make STX token transfer with set tx fee', async () => { memo, anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serialized = bytesToHex(transaction.serialize()); @@ -241,10 +242,10 @@ test('Make STX token transfer with fee estimate', async () => { anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); expect(transaction.auth.spendingCondition?.fee?.toString()).toEqual('180'); const serialized = bytesToHex(transaction.serialize()); - const tx = '0000000001040015c31b8c1c11c515e244b75806bac48d1399c775000000000000000000000000000000b4' + '0001e5ac1152f6018fbfded102268b22086666150823d0ae57f4023bde058a7ff0b279076db25b358b8833' + @@ -275,9 +276,9 @@ test('Make STX token transfer with testnet', async () => { memo: memo, anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serialized = bytesToHex(transaction.serialize()); - const tx = '8080000000040015c31b8c1c11c515e244b75806bac48d1399c77500000000000000000000000000000000' + '00014199f63f7e010141a36a4624d032758f54e08ff03b24ed2667463eb405b4d81505631b32a1f13b5737' + @@ -299,9 +300,9 @@ test('Make STX token transfer with testnet string name', async () => { memo: 'test memo', anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serialized = bytesToHex(transaction.serialize()); - const tx = '8080000000040015c31b8c1c11c515e244b75806bac48d1399c77500000000000000000000000000000000' + '00014199f63f7e010141a36a4624d032758f54e08ff03b24ed2667463eb405b4d81505631b32a1f13b5737' + @@ -395,6 +396,7 @@ test('Make Multi-Sig STX token transfer', async () => { signer.signOrigin(privKeys[0]); signer.signOrigin(privKeys[1]); signer.appendOrigin(pubKeys[2]); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serializedTx = transaction.serialize(); const tx = @@ -644,6 +646,7 @@ test('Make Multi-Sig STX token transfer with two transaction signers', async () const bytesReader = new BytesReader(serializedTx); const deserializedTx = deserializeTransaction(bytesReader); + expect(() => deserializedTx.verifyOrigin()).not.toThrow(); expect(deserializedTx.auth.authType).toBe(authType); @@ -723,6 +726,7 @@ test('Make versioned smart contract deploy', async () => { anchorMode: AnchorMode.Any, clarityVersion: ClarityVersion.Clarity2, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serialized = bytesToHex(transaction.serialize()); @@ -748,6 +752,7 @@ test('Make smart contract deploy (defaults to versioned smart contract, as of 2. network: new StacksTestnet(), anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serialized = bytesToHex(transaction.serialize()); @@ -767,6 +772,7 @@ test('Make smart contract deploy with network string name (defaults to versioned network: 'testnet', anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serialized = bytesToHex(transaction.serialize()); @@ -807,6 +813,37 @@ test('Make smart contract deploy unsigned', async () => { expect(deserializedTx.auth.spendingCondition!.fee!.toString()).toBe(fee.toString()); }); +test('make a multi-sig contract deploy', async () => { + const contractName = 'kv-store'; + const codeBody = fs.readFileSync('./tests/contracts/kv-store.clar').toString(); + const fee = 0; + const nonce = 0; + const privKeyStrings = [ + '6d430bb91222408e7706c9001cfaeb91b08c2be6d5ac95779ab52c6b431950e001', + '2a584d899fed1d24e26b524f202763c8ab30260167429f157f1c119f550fa6af01', + 'd5200dee706ee53ae98a03fba6cf4fdcc5084c30cfa9e1b3462dcdeaa3e0f1d201', + ]; + + const pubKeys = privKeyStrings.map(pubKeyfromPrivKey); + const pubKeyStrings = pubKeys.map(publicKeyToString); + + const transaction = await makeContractDeploy({ + codeBody, + contractName, + publicKeys: pubKeyStrings, + numSignatures: 3, + signerKeys: privKeyStrings, + fee, + nonce, + network: new StacksTestnet(), + anchorMode: AnchorMode.Any, + }); + expect(() => transaction.verifyOrigin()).not.toThrow(); + expect(transaction.auth.spendingCondition!.signer).toEqual( + '04128cacf0764f69b1e291f62d1dcdd8f65be5ab' + ); +}); + test('Make smart contract deploy signed', async () => { const contractName = 'kv-store'; const codeBody = fs.readFileSync('./tests/contracts/kv-store.clar').toString(); @@ -825,6 +862,7 @@ test('Make smart contract deploy signed', async () => { network: new StacksTestnet(), anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serializedTx = transaction.serialize(); @@ -857,6 +895,7 @@ test('Make contract-call', async () => { network: new StacksTestnet(), anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serialized = bytesToHex(transaction.serialize()); @@ -881,6 +920,7 @@ test('Make contract-call with network string', async () => { network: 'testnet', anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serialized = bytesToHex(transaction.serialize()); @@ -952,6 +992,7 @@ test('Make contract-call with post conditions', async () => { postConditionMode: PostConditionMode.Deny, anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serialized = bytesToHex(transaction.serialize()); @@ -996,6 +1037,7 @@ test('Make contract-call with post condition allow mode', async () => { postConditionMode: PostConditionMode.Allow, anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); const serialized = bytesToHex(transaction.serialize()); @@ -1055,12 +1097,11 @@ test('make a multi-sig contract call', async () => { '2a584d899fed1d24e26b524f202763c8ab30260167429f157f1c119f550fa6af01', 'd5200dee706ee53ae98a03fba6cf4fdcc5084c30cfa9e1b3462dcdeaa3e0f1d201', ]; - // const privKeys = privKeyStrings.map(createStacksPrivateKey); const pubKeys = privKeyStrings.map(pubKeyfromPrivKey); const pubKeyStrings = pubKeys.map(publicKeyToString); - const tx = await makeContractCall({ + const transaction = await makeContractCall({ contractAddress, contractName, functionName, @@ -1074,8 +1115,11 @@ test('make a multi-sig contract call', async () => { postConditionMode: PostConditionMode.Allow, anchorMode: AnchorMode.Any, }); + expect(() => transaction.verifyOrigin()).not.toThrow(); - expect(tx.auth.spendingCondition!.signer).toEqual('04128cacf0764f69b1e291f62d1dcdd8f65be5ab'); + expect(transaction.auth.spendingCondition!.signer).toEqual( + '04128cacf0764f69b1e291f62d1dcdd8f65be5ab' + ); }); test('Estimate transaction transfer fee', async () => {